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:
co-authored by
Claude Sonnet 4.6
parent
14d7a47d4d
commit
935accc91f
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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 ─────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -604,12 +604,23 @@
|
||||
<p class="text-sm font-medium text-gray-900 truncate">{{ file.original_name ?? file.name }}</p>
|
||||
<span v-if="file.is_shared" class="shrink-0 bg-indigo-50 text-indigo-600 text-xs font-medium px-2 py-0.5 rounded-full">Shared</span>
|
||||
<!--
|
||||
Phase 14 / D-01: Analyze button — inside the name cell so it does not
|
||||
appear in [data-test="file-row-actions"] (which contains capability-gated
|
||||
buttons). Analyze is always available in cloud mode — not capability-gated.
|
||||
Phase 14.1 / D-06 / D-08 / D-10: Single analysis action slot in the name
|
||||
cell. Swaps between Analyze / Re-analyze / Retry by analysis_status.
|
||||
Only one of the three renders at any given time (v-if / v-else-if chain).
|
||||
|
||||
Analyze — not yet analyzed (pending / no status)
|
||||
Re-analyze — previously indexed or stale; force=true re-queue (D-11)
|
||||
Retry — analysis failed/partial
|
||||
|
||||
Unsupported items render nothing here (analysis_status = "unsupported").
|
||||
|
||||
This replaces the old single "Analyze" button that showed for all
|
||||
cloud files regardless of state (Phase 14 D-01 original).
|
||||
-->
|
||||
<template v-if="mode === 'cloud'">
|
||||
<!-- Analyze: not yet analyzed -->
|
||||
<button
|
||||
v-if="mode === 'cloud'"
|
||||
v-if="cloudRowActionKind(file) === 'analyze'"
|
||||
type="button"
|
||||
data-test="analyze-file"
|
||||
title="Analyze"
|
||||
@@ -619,13 +630,55 @@
|
||||
>
|
||||
<AppIcon name="sparkles" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<!-- Re-analyze: indexed or stale -->
|
||||
<button
|
||||
v-else-if="cloudRowActionKind(file) === 'reanalyze'"
|
||||
type="button"
|
||||
data-test="reanalyze-file"
|
||||
title="Re-analyze"
|
||||
aria-label="Re-analyze"
|
||||
class="shrink-0 px-1.5 py-0.5 rounded text-xs font-medium transition-colors text-violet-600 hover:text-violet-700 hover:bg-violet-50 active:bg-violet-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500 opacity-0 group-hover:opacity-100"
|
||||
@click.stop="$emit('analyze-file', file)"
|
||||
>
|
||||
Re-analyze
|
||||
</button>
|
||||
<!-- Retry: failed analysis -->
|
||||
<button
|
||||
v-else-if="cloudRowActionKind(file) === 'retry'"
|
||||
type="button"
|
||||
data-test="retry-analysis-file"
|
||||
title="Retry analysis"
|
||||
aria-label="Retry analysis"
|
||||
class="shrink-0 px-1.5 py-0.5 rounded text-xs font-medium transition-colors text-amber-600 hover:text-amber-700 hover:bg-amber-50 active:bg-amber-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500 opacity-0 group-hover:opacity-100"
|
||||
@click.stop="$emit('analyze-file', file)"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<!--
|
||||
Phase 14.1 / D-06: Inline status badge for cloud file rows.
|
||||
Shows current analysis state in the same relative slot as the local
|
||||
file row status (name-cell zone). Only rendered for cloud files that
|
||||
have been through analysis (not pending).
|
||||
Colors per UI-SPEC: green current, amber stale/skipped, blue working, red failed.
|
||||
-->
|
||||
<div v-if="mode === 'cloud' && file.analysis_status && file.analysis_status !== 'pending'"
|
||||
class="flex items-center gap-1 mt-0.5 flex-wrap"
|
||||
>
|
||||
<span
|
||||
:class="['inline-flex items-center px-1.5 py-0 rounded-full text-xs font-medium', cloudRowStatusBadgeClass(file.analysis_status)]"
|
||||
:data-test="`analysis-status-${file.analysis_status}`"
|
||||
>
|
||||
{{ cloudRowStatusLabel(file.analysis_status) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="file.topics?.length" class="flex items-center gap-1 mt-0.5 flex-wrap">
|
||||
<TopicBadge
|
||||
v-for="t in file.topics.slice(0, 3)"
|
||||
:key="t"
|
||||
:name="t"
|
||||
:color="topicColor(t)"
|
||||
v-for="t in (typeof file.topics[0] === 'string' ? file.topics : file.topics.map(tt => tt.name)).slice(0, 3)"
|
||||
:key="typeof t === 'string' ? t : t.name"
|
||||
:name="typeof t === 'string' ? t : t.name"
|
||||
:color="topicColor(typeof t === 'string' ? t : t.name)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -762,6 +815,12 @@ import DropZone from '../upload/DropZone.vue'
|
||||
import UploadProgress from '../upload/UploadProgress.vue'
|
||||
import TopicBadge from '../topics/TopicBadge.vue'
|
||||
import { formatDate, formatSize } from '../../utils/formatters.js'
|
||||
import { useCloudConnectionsStore } from '../../stores/cloudConnections.js'
|
||||
|
||||
// D-06: Single status translation source — store owns the canonical map.
|
||||
// StorageBrowser delegates to this; the local translateStatus function below
|
||||
// also delegates so existing call-sites are preserved.
|
||||
const cloudStore = useCloudConnectionsStore()
|
||||
|
||||
// ── CapabilityButton inline component ──────────────────────────────────────────
|
||||
/**
|
||||
@@ -1018,21 +1077,67 @@ const internalSelectedItems = ref(new Set(props.selectedItems ?? []))
|
||||
/** Expand/collapse state for the analysis item queue list. */
|
||||
const analysisQueueExpanded = ref(false)
|
||||
|
||||
/** D-06: Internal simplified status translation for display. */
|
||||
/**
|
||||
* D-06: Status translation delegates to the store's translateAnalysisStatus.
|
||||
* The store is the single source of truth for internal status → UI label mapping
|
||||
* (CLAUDE.md: "translateAnalysisStatus in cloudConnections store is the single
|
||||
* translation source — components never translate internal status strings
|
||||
* independently").
|
||||
*
|
||||
* The function signature is preserved so existing call-sites (statusBadgeClass,
|
||||
* etc.) continue to work without modification.
|
||||
*/
|
||||
function translateStatus(status) {
|
||||
const SIMPLIFIED = {
|
||||
queued: 'waiting',
|
||||
downloading: 'working',
|
||||
extracting: 'working',
|
||||
classifying: 'working',
|
||||
indexed: 'done',
|
||||
already_current: 'done',
|
||||
cancelled: 'skipped',
|
||||
failed: 'failed',
|
||||
unsupported: 'skipped',
|
||||
stale: 'working',
|
||||
return cloudStore.translateAnalysisStatus(status)
|
||||
}
|
||||
return SIMPLIFIED[status] ?? status
|
||||
|
||||
/**
|
||||
* D-06: Determine the analysis action kind for a cloud file row.
|
||||
* Returns 'analyze' | 'reanalyze' | 'retry' | null based on analysis_status.
|
||||
*
|
||||
* - null/pending/none → 'analyze' (not yet analyzed)
|
||||
* - indexed/current/stale → 'reanalyze' (analyzed; re-analyze available)
|
||||
* - failed/partial → 'retry' (failed; retry available)
|
||||
* - unsupported → null (analysis not supported — no action rendered)
|
||||
*/
|
||||
function cloudRowActionKind(file) {
|
||||
const status = file.analysis_status
|
||||
if (!status || status === 'pending' || status === 'none') return 'analyze'
|
||||
if (status === 'indexed' || status === 'already_current' || status === 'stale' || status === 'current') return 'reanalyze'
|
||||
if (status === 'failed' || status === 'partial') return 'retry'
|
||||
if (status === 'unsupported') return null
|
||||
// Default: show analyze for unknown states
|
||||
return 'analyze'
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 14.1 / D-06: CSS class for inline analysis-status badge in cloud file rows.
|
||||
* Maps simplified status to a color per the UI-SPEC:
|
||||
* green — current/indexed
|
||||
* amber — stale/skipped
|
||||
* blue — working/waiting
|
||||
* red — failed
|
||||
*/
|
||||
function cloudRowStatusBadgeClass(status) {
|
||||
const simplified = translateStatus(status)
|
||||
if (simplified === 'done') return 'bg-green-100 text-green-700'
|
||||
if (simplified === 'skipped') return 'bg-amber-100 text-amber-700'
|
||||
if (simplified === 'working' || simplified === 'waiting') return 'bg-blue-100 text-blue-700'
|
||||
if (simplified === 'failed') return 'bg-red-100 text-red-700'
|
||||
return 'bg-gray-100 text-gray-500'
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 14.1 / D-06: Human-readable status label for inline cloud file row badge.
|
||||
* Stale shows "stale" (not "working") in the row — distinct from queue-item status.
|
||||
*/
|
||||
function cloudRowStatusLabel(status) {
|
||||
if (status === 'stale') return 'stale'
|
||||
if (status === 'indexed' || status === 'already_current' || status === 'current') return 'indexed'
|
||||
if (status === 'failed') return 'failed'
|
||||
if (status === 'unsupported') return 'unsupported'
|
||||
if (status === 'pending' || !status) return 'pending'
|
||||
return translateStatus(status)
|
||||
}
|
||||
|
||||
/** Toggle a file in the internal selection set (cloud mode only). */
|
||||
|
||||
Reference in New Issue
Block a user