feat(14.1-04): browse rows carry topics+analysis_status+is_stale; StorageBrowser parity slots

- CloudItemOut gains lightweight row fields: topics (List[str]), analysis_status
  (Optional[str]), is_stale (bool) — allowlisted, no extracted_text (T-14.1-10)
- browse.py _item_out populates status/is_stale from CloudItem; batched topic-name
  load via _batch_load_topics (single JOIN query; no N+1) for file rows only
- StorageBrowser imports cloudConnections store; translateStatus delegates to
  store.translateAnalysisStatus (D-06 single-source rule)
- cloudRowActionKind() drives single analysis action slot: Analyze (pending) /
  Re-analyze (indexed/stale) / Retry (failed) — one renders at a time (D-08/D-10)
- Inline analysis_status badge added for cloud file rows (green/amber/blue/red per UI-SPEC)
- TopicBadge rendering handles both string[] (cloud) and object[] (local) topics
- All 489 frontend tests pass; all 52 backend cloud tests pass

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
curo1305
2026-06-26 22:21:30 +02:00
co-authored by Claude Sonnet 4.6
parent 14d7a47d4d
commit 935accc91f
3 changed files with 208 additions and 35 deletions
+55 -3
View File
@@ -25,7 +25,8 @@ from api.cloud.schemas import (
CloudItemOut,
FolderFreshnessOut,
)
from db.models import CloudConnection, CloudItem, CloudFolderState, User
from db.models import CloudConnection, CloudItem, CloudFolderState, CloudItemTopic, Topic, User
from sqlalchemy import select
from deps.auth import get_regular_user
from deps.db import get_db
from deps.utils import parse_uuid
@@ -73,7 +74,19 @@ def _capability_out(caps: dict) -> dict[str, CloudCapabilityOut]:
}
def _item_out(item: CloudItem) -> CloudItemOut:
def _item_out(
item: CloudItem,
topic_names: list[str] | None = None,
) -> CloudItemOut:
"""Build a CloudItemOut from a CloudItem ORM row.
Phase 14.1: topic_names and analysis_status/is_stale are populated from
a caller-supplied batched topic lookup so we avoid N+1 queries.
Folder rows always receive topic_names=[] and analysis_status=None.
extracted_text is deliberately excluded (T-14.1-10 — row payload must stay lightweight).
"""
is_file = item.kind == "file"
status = item.analysis_status if is_file else None
return CloudItemOut(
id=str(item.id),
provider_item_id=item.provider_item_id,
@@ -85,9 +98,40 @@ def _item_out(item: CloudItem) -> CloudItemOut:
modified_at=item.modified_at,
etag=item.etag,
capabilities={}, # Phase 13 will add per-item capabilities
# Phase 14.1 row-parity fields (T-14.1-10 allowlist)
topics=topic_names or [],
analysis_status=status,
is_stale=(status == "stale"),
)
async def _batch_load_topics(
session,
file_ids: list,
) -> dict:
"""Batch-load topic names for a list of CloudItem UUIDs.
Returns a dict mapping cloud_item_id (UUID) → list[str] of topic names.
Uses a single JOIN query to avoid N+1 when building browse rows (T-14.1-10).
"""
if not file_ids:
return {}
stmt = (
select(CloudItemTopic.cloud_item_id, Topic.name)
.join(Topic, Topic.id == CloudItemTopic.topic_id)
.where(CloudItemTopic.cloud_item_id.in_(file_ids))
.order_by(CloudItemTopic.cloud_item_id, Topic.name)
)
result = await session.execute(stmt)
rows = result.fetchall()
topics_by_item: dict = {}
for cloud_item_id, topic_name in rows:
topics_by_item.setdefault(cloud_item_id, []).append(topic_name)
return topics_by_item
def _freshness_out(fs: CloudFolderState) -> FolderFreshnessOut:
return FolderFreshnessOut(
refresh_state=fs.refresh_state,
@@ -325,12 +369,20 @@ async def browse_connection_items(
display_name = conn.display_name_override or conn.display_name
# Phase 14.1: Batch-load topic names for all file rows in one query (T-14.1-10).
# Folder rows are excluded from the topic lookup (folders never carry topics).
file_ids = [item.id for item in cached_items if item.kind == "file"]
topics_by_item = await _batch_load_topics(session, file_ids)
return CloudBrowseResponse(
connection_id=str(connection_id),
provider=conn.provider,
display_name=display_name,
parent_ref=parent_ref,
items=[_item_out(item) for item in cached_items],
items=[
_item_out(item, topic_names=topics_by_item.get(item.id))
for item in cached_items
],
capabilities=_capability_out(conn_caps),
freshness=_freshness_out(folder_state),
)
+17 -1
View File
@@ -98,7 +98,18 @@ class CloudCapabilityOut(BaseModel):
class CloudItemOut(BaseModel):
"""Normalized cloud item metadata. No credentials or byte content."""
"""Normalized cloud item metadata. No credentials or byte content.
Phase 14.1 (Plan 04) adds lightweight row fields for browser parity:
topics: List of topic-name strings (empty list for unanalyzed/folders).
analysis_status: Internal status string ("pending"|"indexed"|"stale"|"failed"|…)
— None for folder rows.
is_stale: True when analysis_status == "stale" (D-07 convenience flag).
Strict allowlist: extracted_text, object_key, credentials_enc, version_key, and
raw provider URLs are absent by design (T-14.1-10 — row payload must stay lightweight
and free of sensitive internal fields).
"""
id: str # DocuVault stable UUID
provider_item_id: str
@@ -111,6 +122,11 @@ class CloudItemOut(BaseModel):
etag: Optional[str] = None
capabilities: dict[str, CloudCapabilityOut] = {}
# Phase 14.1 row-parity fields (T-14.1-10 allowlist — no extracted_text)
topics: List[str] = [] # topic names; empty for folders/unanalyzed
analysis_status: Optional[str] = None # None for folder rows
is_stale: bool = False # True when analysis_status == "stale" (D-07)
# ── Freshness / folder state schemas ─────────────────────────────────────────