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:
JesseMarkowitz
2026-08-17 12:49:04 -04:00
parent dfa0645fba
commit 710889b65f
3 changed files with 138 additions and 5 deletions
+80
View File
@@ -1299,3 +1299,83 @@ class TestDeletedAssetReporting:
),
)
assert _classify_failure(err) == "expired-or-missing"
# ---------------------------------------------------------------------------
# Project attribution: a conversation names its own project via gizmo_id, so
# it should not depend on the user having listed that project in config.
# ---------------------------------------------------------------------------
class TestProjectAttribution:
def _provider(self, *, project_ids=None, gizmo_name="My Project"):
from src.providers.chatgpt import ChatGPTProvider
p = ChatGPTProvider.__new__(ChatGPTProvider)
p._project_map = {}
p._project_name_cache = {}
p._project_ids = project_ids or []
p._hidden_content = "placeholder"
p._fetch_project_name = lambda gid: gizmo_name
return p
def _raw(self, **extra):
raw = {
"conversation_id": "conv-1",
"title": "T",
"create_time": 1_700_000_000,
"update_time": 1_700_000_000,
"mapping": {},
}
raw.update(extra)
return raw
def test_gizmo_id_supplies_project_when_unconfigured(self):
p = self._provider(gizmo_name="Tech Questions")
out = p.normalize_conversation(self._raw(gizmo_id="g-p-abc123"))
assert out["project"] == "Tech Questions"
def test_explicit_annotation_still_wins(self):
p = self._provider(gizmo_name="From Gizmo")
out = p.normalize_conversation(
self._raw(gizmo_id="g-p-abc123", _project_name="From Listing")
)
assert out["project"] == "From Listing"
def test_project_map_beats_gizmo_lookup(self):
p = self._provider(gizmo_name="From Gizmo")
p._project_map["conv-1"] = "From Map"
out = p.normalize_conversation(self._raw(gizmo_id="g-p-abc123"))
assert out["project"] == "From Map"
def test_custom_gpt_is_not_treated_as_a_project(self):
"""Only g-p- ids are projects; a custom GPT must not become a folder."""
p = self._provider()
out = p.normalize_conversation(self._raw(gizmo_id="g-xyz789"))
assert out["project"] is None
def test_no_gizmo_id_stays_unprojected(self):
p = self._provider()
assert p.normalize_conversation(self._raw())["project"] is None
def test_resolved_project_is_cached_into_the_map(self):
p = self._provider(gizmo_name="Cached")
p.normalize_conversation(self._raw(gizmo_id="g-p-abc123"))
assert p._project_map["conv-1"] == "Cached"
def test_unconfigured_project_is_reported_once(self, caplog):
p = self._provider(project_ids=["g-p-known"], gizmo_name="Surprise")
with caplog.at_level(logging.INFO):
p.normalize_conversation(self._raw(gizmo_id="g-p-surprise"))
p.normalize_conversation(
self._raw(conversation_id="conv-2", gizmo_id="g-p-surprise")
)
hits = [r for r in caplog.records if "not in CHATGPT_PROJECT_IDS" in r.message]
assert len(hits) == 1
assert "Surprise" in hits[0].message
def test_configured_project_is_not_reported(self, caplog):
p = self._provider(project_ids=["g-p-known"], gizmo_name="Known")
with caplog.at_level(logging.INFO):
p.normalize_conversation(self._raw(gizmo_id="g-p-known"))
assert not [r for r in caplog.records if "not in CHATGPT_PROJECT_IDS" in r.message]