fix: read project attribution from the conversation's gizmo_id
A conversation in a project absent from CHATGPT_PROJECT_IDS exported into no-project/ even though its own payload names the project. Found while investigating the media 403s: bpi-f3-case-options sits in g-p-6a4edf5160848191927b05da20f49151, which is not among the 13 configured ids, so it filed under no-project.2026. Both existing sources are bounded by what the user configured. The detail response is not: it carries gizmo_id. Use it as a third fallback, after the listing annotation and the project map, and cache the result into the map. Attribution now stays correct with no list to maintain, and moving a chat into a new project stops silently misfiling it. Only g-p- ids are treated as projects — a custom GPT is not a project and must not become a folder. CHATGPT_PROJECT_IDS still matters for the listing pass: conversations that live only inside a project never appear in the default listing, so an unconfigured project's chats can be missed entirely. Each one is now reported once per run with the id to add, which turns "some chats are missing" into a line to paste. 8 tests cover precedence, the g-p- guard, map caching and the once-per-run report. 318 pass.
This commit is contained in:
@@ -401,6 +401,34 @@ class ChatGPTProvider(BaseProvider):
|
||||
self._project_name_cache[project_id] = name
|
||||
return name
|
||||
|
||||
def _note_unconfigured_project(self, gizmo_id: str, name: str) -> None:
|
||||
"""Report a project discovered from a conversation but absent from config.
|
||||
|
||||
Attribution now works without CHATGPT_PROJECT_IDS, but the listing pass
|
||||
still uses it: project-only conversations never appear in the default
|
||||
listing, so an unconfigured project's chats are exported only if they
|
||||
surface some other way. Naming the gap once per run is the difference
|
||||
between "some chats are missing" and knowing which line to add.
|
||||
"""
|
||||
configured = getattr(self, "_project_ids", None) or []
|
||||
if gizmo_id in configured:
|
||||
return
|
||||
seen = getattr(self, "_unconfigured_projects", None)
|
||||
if seen is None:
|
||||
seen = set()
|
||||
self._unconfigured_projects = seen
|
||||
if gizmo_id in seen:
|
||||
return
|
||||
seen.add(gizmo_id)
|
||||
logger.info(
|
||||
"[chatgpt] Project '%s' (%s) is not in CHATGPT_PROJECT_IDS — "
|
||||
"attribution resolved from the conversation, but conversations "
|
||||
"that live only in this project are not being listed. Add it to "
|
||||
"CHATGPT_PROJECT_IDS to fetch them.",
|
||||
name,
|
||||
gizmo_id,
|
||||
)
|
||||
|
||||
def list_project_conversations(
|
||||
self, project_id: str, cursor: str = "0"
|
||||
) -> tuple[list[dict], str | None]:
|
||||
@@ -797,15 +825,37 @@ class ChatGPTProvider(BaseProvider):
|
||||
updated_at = _ts_to_iso(raw.get("update_time"))
|
||||
|
||||
# Prefer _project_name annotation injected from the listing summary
|
||||
# (propagated by the export loop). Fall back to _project_map lookup.
|
||||
project = raw.get("_project_name") or (
|
||||
self._project_map.get(conv_id) if conv_id else None
|
||||
)
|
||||
# (propagated by the export loop), then the _project_map built from
|
||||
# CHATGPT_PROJECT_IDS, then the conversation's own gizmo_id.
|
||||
#
|
||||
# That last fallback matters: both earlier sources are limited to the
|
||||
# projects the user configured, so a conversation in an unconfigured
|
||||
# project still exports (it appears in the default listing) but landed
|
||||
# under no-project. The detail payload names its own project, so read
|
||||
# it from there and the attribution stays correct without the user
|
||||
# having to maintain a list. Observed 2026-08-17: a conversation whose
|
||||
# gizmo_id was absent from CHATGPT_PROJECT_IDS filed under no-project.
|
||||
source = "_project_name"
|
||||
project = raw.get("_project_name")
|
||||
if not project and conv_id:
|
||||
project = self._project_map.get(conv_id)
|
||||
source = "_project_map"
|
||||
if not project:
|
||||
gizmo_id = raw.get("gizmo_id")
|
||||
# gizmo_type distinguishes a project from a custom GPT; only a
|
||||
# project should become a folder. Absent on older payloads, so
|
||||
# treat "unknown but has a project-shaped id" as a project.
|
||||
if gizmo_id and str(gizmo_id).startswith("g-p-"):
|
||||
project = self._fetch_project_name(gizmo_id)
|
||||
source = "gizmo_id"
|
||||
if conv_id:
|
||||
self._project_map[conv_id] = project
|
||||
self._note_unconfigured_project(gizmo_id, project)
|
||||
logger.debug(
|
||||
"[chatgpt] normalize_conversation[%s]: project=%r (source=%s)",
|
||||
conv_id[:8] if conv_id else "?",
|
||||
project,
|
||||
"_project_name" if raw.get("_project_name") else "_project_map",
|
||||
source,
|
||||
)
|
||||
|
||||
mapping: dict = raw.get("mapping", {})
|
||||
|
||||
Reference in New Issue
Block a user