diff --git a/AGENTS.md b/AGENTS.md index 1728f9e61..858175ccb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,7 @@ reimplementing them: tree assembly (`reconstruct.py`'s `_walk`/`thread_messages` calls). - [RankWeave](https://github.com/ContextualWisdomLab/RankWeave) for multi-channel score fusion (`weighted_convex_fuse` in - `reconstruct.py`) and the buyer-facing Rankings port + `reconstruct.py`) and the reader-facing Rankings port (`rankweave_client.py`) -- never invent a fused score or a theta. - [TEPP](https://github.com/ContextualWisdomLab/TEPP)'s published wire contract for calibrated measurement (`tepp_client.py`) -- never @@ -127,7 +127,7 @@ contextual-orchestrator owns model discovery and selection. retaining the original asset and provenance. Recognize image DOM/visual regions before OCR, descriptions, Keyman extraction, or embeddings. Store region-level evidence; never show an internal LLM instruction such as - `This post is an image` to a buyer. + `This post is an image` to a reader. ## Source parsing and semantic units @@ -146,10 +146,10 @@ contextual-orchestrator owns model discovery and selection. - Remove presentation-only visual line alignment inside a paragraph (for example continuation lines manually aligned after `-`, `*`, `1.`, or `.`) from derived semantic text, while retaining the source body and meaningful - list/heading nesting. A buyer-facing post view must render semantic + list/heading nesting. A reader-facing post view must render semantic paragraphs, not the authoring application's spacing workaround. - Image descriptions, OCR text, and region evidence are analysis artifacts, - not buyer-facing prompt instructions. Buyer UI shows the source content and + not reader-facing prompt instructions. The workspace UI shows the source content and useful captions/evidence only, with provenance where appropriate. ## Pluggable channels: never fake a missing signal diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d0280ff97..d4ff2e159 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -64,7 +64,7 @@ flowchart LR | `chunking.py` | Splits a document into meaning-identifiable units (paragraph, sentence, DOM, conversation-turn) plus embedded-image extraction, in document order | | `embedding_client.py` | Pluggable text-embedding channel (`Null` default, `OpenAiCompatible` real impl) + `chunked_max_similarity` | | `adjudication_client.py` | Pluggable LLM-judgment channel (`Null` default, `ContextualOrchestrator` real impl) | -| `image_content.py` | Pluggable vision channel: OCR + object recognition/tagging for embedded images (`Null` default, `OpenAiCompatibleVisionClient` real impl). The product popup (`frontend/src/PostBody.tsx`) renders each `data:image` payload in document order so the buyer sees the picture, not the base64 string; GET does not call the vision client. | +| `image_content.py` | Pluggable vision channel: OCR + object recognition/tagging for embedded images (`Null` default, `OpenAiCompatibleVisionClient` real impl). The product popup (`frontend/src/PostBody.tsx`) renders each `data:image` payload in document order so the reader sees the picture, not the base64 string; GET does not call the vision client. | | `tepp_client.py` | TEPP's published `AnalysisRunRequest` wire contract, pluggable transport | | `rankweave_client.py` | Fail-closed RankWeave ranking port (`weighted_reciprocal_rank_fuse` in-process; never invent a fused score or a theta) | | `reconstruct.py` | The pipeline: group → candidate window → score → fuse → thread | @@ -326,7 +326,7 @@ Keymen are affiliated with (`lineageweave/affiliate_tree.py`, loaded by set of those leaves, not the whole company directory -- a sibling the post never mentions is omitted. People on the tree are buttons that reuse `GET /api/keymen/{person_id}/related` so the popup Keyman walk -starts from the affiliation the buyer clicked. A resolved organization +starts from the affiliation the reader clicked. A resolved organization is the same walk via `GET /api/corporate-entities/{id}/related`. An affiliation that did not resolve to a `corporate_entity` row stays as its own root (`resolved=false`); that is the same never-guess-a-parent rule @@ -467,7 +467,7 @@ truly have no dated open tickets. ## Phase 6-M2: authorized analysis-run evidence (read projection) -Issue #79's first buyer-visible Milestone 2 slice is a source-redacting +Issue #79's first reader-visible Milestone 2 slice is a source-redacting read of the #89 registry. `GET /api/analysis-runs` and `GET /api/analysis-runs/{id}` require `post_read` and apply the scope in SQL: the requester always sees their own run; a corporate-entity or @@ -475,7 +475,7 @@ process-unit scope is visible only to affiliated accounts; a thread-group scope is visible only when the account can already see a post in that group; `all_visible` is requester-only. Hidden runs 404. Detail also lists ABAC-visible post titles in the run's scope whose `created_at` is at or before `knowledge_cutoff` -(ADR 0016) so a buyer can open a post the run was allowed to know +(ADR 0016) so a reader can open a post the run was allowed to know without seeing later live rows or hidden bodies. Detail also returns revision and configuration digest prefixes. `POST /api/analysis-runs` records a Pending lineage run on a new @@ -925,7 +925,7 @@ code. Wired into both `keyman_ingestion.py`'s affiliation loop and ## Standards-complete W3C PROV-O provenance layer ADR 0011 separates standards-complete provenance from the compact -buyer-facing navigation graph. `lineageweave/prov_o.py` validates +reader-facing navigation graph. `lineageweave/prov_o.py` validates and materializes all 50 normative PROV-O properties, including literal-valued times/values and qualified Influence resources. `migrations/0017_prov_o_standard_relations.sql` stores definitions, diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b8b87a19..605b79a1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,31 @@ All notable changes to this project are documented here. Format follows ## [Unreleased] +### Changed + +- Renamed "Buyer" terminology to reader/workspace naming across the frontend + shell, backend evidence helpers, and living docs (ADR 0119). LineageWeave + has no explicit buyer role, so `BuyerNav`/`BuyerDestination` became + `WorkspaceNav`/`WorkspaceDestination`, `.buyer-gnb*` CSS became + `.workspace-gnb*`, and prose referring to the reading user now says + "reader" instead of "buyer". Historical ADRs and changelog entries keep + their original wording as a point-in-time record. + ### Fixed - `make smoke` and `make seed` now run through the locked project `uv` environment, so local OIDC and synthetic-data workflows resolve the same pinned dependencies as CI. +- The workspace Event Lineage global Search action now retries focus after the + board finishes loading, so navigation from Customer master, Calendar, or + Ask Agent lands the cursor in the search box. The handled request is consumed, + so later board navigation does not steal focus, and Search closes an open + mobile drawer like every other destination change. +- Mobile Event Lineage evidence cards now read their translated column labels + from the rendered cells instead of hardcoded English CSS, and the two drawer + close controls have distinct accessible names. +- Event Lineage SVG edges now retain their instance-specific direction markers, + so parent-to-child arrows remain visible when multiple lineage groups render. - All OpenAI-compatible chat-completion consumers now validate the shared response envelope before parsing it, preventing malformed provider bodies from escaping as raw `KeyError` or response-shape details. diff --git a/backend/app/analysis_run_ingestion.py b/backend/app/analysis_run_ingestion.py index f7da2969b..fb76ad821 100644 --- a/backend/app/analysis_run_ingestion.py +++ b/backend/app/analysis_run_ingestion.py @@ -249,7 +249,7 @@ async def fetch_outbox_deliveries( """Labeled claim/delivery events for one already-visible run. Missing outbox tables mean migration 0023 is not applied. Stream - entry ids stay off the payload -- they are not buyer evidence. + entry ids stay off the payload -- they are not reader-facing evidence. """ try: rows = await conn.fetch( @@ -285,7 +285,7 @@ async def _serialize_runs( conn: asyncpg.Connection, rows: list[asyncpg.Record], ) -> list[dict[str, Any]]: - """Project registry rows into the authorized buyer-facing payload.""" + """Project registry rows into the authorized reader-facing payload.""" if not rows: return [] count_rows = await _counts_by_run(conn, [str(row["analysis_run_id"]) for row in rows]) @@ -346,7 +346,7 @@ async def fetch_visible_analysis_runs( """Runs the account requested or whose scope they may already walk. Once real source-import evidence is visible, the synthetic `make seed` - Demo Corp runs stop appearing here -- a buyer must not mistake that + Demo Corp runs stop appearing here -- a reader must not mistake that fabricated narrative for real evidence (ADR 0001 / ADR 0042). """ # Safe SQL: this immutable module query contains only closed schema SQL; request values remain bound below. diff --git a/backend/app/demo_scope.py b/backend/app/demo_scope.py index 6d25bee24..811a0e673 100644 --- a/backend/app/demo_scope.py +++ b/backend/app/demo_scope.py @@ -4,7 +4,7 @@ something to show. Once an account can see at least one post carrying real source-import evidence, the synthetic Demo Corp tree is no longer needed to fill an empty screen and must stop appearing next to real evidence -- a -buyer must never mistake a fabricated contact (e.g. Ada West, Priya Nair) +reader must never mistake a fabricated contact (e.g. Ada West, Priya Nair) for a real one. """ diff --git a/backend/app/entity_relationship_ingestion.py b/backend/app/entity_relationship_ingestion.py index da22a9136..815628ef0 100644 --- a/backend/app/entity_relationship_ingestion.py +++ b/backend/app/entity_relationship_ingestion.py @@ -128,11 +128,11 @@ async def fetch_relationship_network( can be a customer in one post, a competitor in another (their own product line competes with ours elsewhere), the customer of our customer in a third, or a supplier -- Customer Master's per-post - reads never rolled these up, so buyers could only see one role at + reads never rolled these up, so readers could only see one role at a time and never the entity's whole network. This groups every visible, eligible post's classifications by counterparty name, keeping every distinct relationship type observed (not just the - most frequent), so a buyer can see a name marked both Customer and + most frequent), so a reader can see a name marked both Customer and Competitor and know that reflects the real, mixed relationship rather than a classification error. diff --git a/backend/app/issue_ticket_ingestion.py b/backend/app/issue_ticket_ingestion.py index 2cb3fe01d..021a3c398 100644 --- a/backend/app/issue_ticket_ingestion.py +++ b/backend/app/issue_ticket_ingestion.py @@ -173,7 +173,7 @@ async def upsert_commitment_ticket( Re-deriving the same post must not stack duplicate calendar rows -- an existing open ticket with a commitment_summary is updated in place. - A closed ticket is left alone so the buyer can keep the historical + A closed ticket is left alone so the reader can keep the historical record and still derive a fresh open one. """ existing = await conn.fetchrow( diff --git a/backend/app/knowledge_graph.py b/backend/app/knowledge_graph.py index 71304ce92..cb4e3804f 100644 --- a/backend/app/knowledge_graph.py +++ b/backend/app/knowledge_graph.py @@ -576,7 +576,7 @@ async def fetch_person_role_history( responsibility, in posts at different times (a job change, a title change, a move between projects). ``post_summary_role`` already carries this per post; this simply orders it chronologically for - one person instead of leaving a buyer to open every post that + one person instead of leaving a reader to open every post that mentions them and compare manually. ``visible_post_ids`` must already be ABAC-filtered by the caller @@ -590,7 +590,7 @@ async def fetch_person_role_history( rows = await conn.fetch( """ select role.post_id, post.post_title, post.created_at, - role.responsibility, role.affiliated_organization_name + role.responsibility_text, role.affiliated_organization_name from post_summary_role role join source_post post on post.post_id = role.post_id where role.cataloged_person_id = $1 @@ -605,7 +605,7 @@ async def fetch_person_role_history( "post_id": str(row["post_id"]), "post_title": row["post_title"], "created_at": row["created_at"].isoformat(), - "responsibility": row["responsibility"], + "responsibility": row["responsibility_text"], "affiliated_organization_name": row["affiliated_organization_name"], } for row in rows diff --git a/backend/app/main.py b/backend/app/main.py index a1cadb8fc..8a71dcab6 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -507,11 +507,11 @@ async def _load_project_evidence( ) rows = await conn.fetch( """ - select project_key, project_name, evidence_text, confidence, + select project_key, project_name, evidence_text, mention_confidence, ontology_iri, extraction_method from post_project_mention where post_id = $1 - order by confidence desc, project_name, project_key + order by mention_confidence desc, project_name, project_key """, post_id, ) @@ -520,7 +520,7 @@ async def _load_project_evidence( "project_key": row["project_key"], "project_name": row["project_name"], "evidence": row["evidence_text"], - "confidence": float(row["confidence"]), + "confidence": float(row["mention_confidence"]), "ontology_iri": row["ontology_iri"], "ontology_label": "Project", "extraction_method": row["extraction_method"], @@ -582,15 +582,15 @@ async def _post_filter_options( ) -@app.get("/healthz") - @app.get("/api/settings", response_model=dict) async def read_tenant_settings( account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), ): async with pool.acquire() as conn: - row = await conn.fetchrow("SELECT brand_name FROM tenant_settings WHERE id = 1") + row = await conn.fetchrow( + "SELECT brand_name FROM tenant_settings WHERE tenant_settings_id = 1" + ) if not row: return {"brandName": "LineageWeave"} return {"brandName": row["brand_name"]} @@ -606,13 +606,14 @@ async def update_tenant_settings( brand_name = payload.get("brandName", "LineageWeave") async with pool.acquire() as conn: await conn.execute( - "INSERT INTO tenant_settings (id, brand_name) VALUES (1, $1) " - "ON CONFLICT (id) DO UPDATE SET brand_name = $1", + "INSERT INTO tenant_settings (tenant_settings_id, brand_name) VALUES (1, $1) " + "ON CONFLICT (tenant_settings_id) DO UPDATE SET brand_name = $1", brand_name ) return {"brandName": brand_name} +@app.get("/healthz") async def healthz() -> dict[str, str]: """Liveness probe: the process is up. Does not touch Postgres.""" return {"status": "ok"} @@ -629,30 +630,59 @@ async def read_me( ``POST /api/analysis-runs`` should cover. """ entities: list[dict[str, str]] = [] + account_affiliations: list[dict[str, Any]] = [] if account.corporate_entity_ids: async with pool.acquire() as conn: rows = await conn.fetch( """ - select corporate_entity_id, entity_name - from corporate_entity - where corporate_entity_id = any($1::uuid[]) - order by entity_name + select affiliation.corporate_entity_id, + entity.corporate_entity_code, + entity.entity_name, + affiliation.process_unit_id, + process.process_unit_code, + process.process_unit_name + from account_affiliation affiliation + join corporate_entity entity + on entity.corporate_entity_id = affiliation.corporate_entity_id + left join process_unit process + on process.process_unit_id = affiliation.process_unit_id + and process.corporate_entity_id = affiliation.corporate_entity_id + where affiliation.user_account_id = $1 + and affiliation.corporate_entity_id = any($2::uuid[]) + order by entity.entity_name, process.process_unit_code nulls first """, + account.user_account_id, list(account.corporate_entity_ids), ) - entities = [ - { - "corporate_entity_id": str(row["corporate_entity_id"]), - "entity_name": row["entity_name"], - } - for row in rows - ] + seen_entities: set[str] = set() + for row in rows: + entity_id = str(row["corporate_entity_id"]) + if entity_id not in seen_entities: + entities.append( + { + "corporate_entity_id": entity_id, + "corporate_entity_code": row["corporate_entity_code"], + "entity_name": row["entity_name"], + } + ) + seen_entities.add(entity_id) + account_affiliations.append( + { + "corporate_entity_id": entity_id, + "corporate_entity_code": row["corporate_entity_code"], + "entity_name": row["entity_name"], + "process_unit_id": str(row["process_unit_id"]) if row["process_unit_id"] else None, + "process_unit_code": row["process_unit_code"], + "process_unit_name": row["process_unit_name"], + } + ) return { "user_account_id": account.user_account_id, "display_name": account.display_name, "preferred_locale": account.preferred_locale, "permission_codes": sorted(account.permission_codes), "corporate_entities": entities, + "account_affiliations": account_affiliations, } @@ -1258,7 +1288,7 @@ async def list_posts( select 1 from post_summary_role role where role.post_id = post.post_id and (role.actor_name ilike '%' || $1 || '%' - or role.responsibility ilike '%' || $1 || '%' + or role.responsibility_text ilike '%' || $1 || '%' or coalesce(role.affiliated_organization_name, '') ilike '%' || $1 || '%' or (char_length($1) >= 3 and word_similarity(lower($1), lower(role.actor_name)) >= 0.45)) ) @@ -1348,21 +1378,21 @@ async def list_posts( 'project_key', project.project_key, 'project_name', project.project_name, 'evidence', project.evidence_text, - 'confidence', project.confidence, + 'confidence', project.mention_confidence, 'ontology_iri', project.ontology_iri, 'ontology_label', 'Project', 'extraction_method', project.extraction_method, 'resolution_status', 'semantic_candidate', 'provenance', 'post_project_mention.evidence_text' ) - order by project.confidence desc, project.project_name, project.project_key + order by project.mention_confidence desc, project.project_name, project.project_key ) as project_evidence from ( - select project_key, project_name, evidence_text, confidence, + select project_key, project_name, evidence_text, mention_confidence, ontology_iri, extraction_method from post_project_mention where post_id = page.post_id - order by confidence desc, project_name, project_key + order by mention_confidence desc, project_name, project_key limit 5 ) project ) projects on true @@ -1467,7 +1497,7 @@ async def read_post_content( pool: asyncpg.Pool = Depends(get_pool), valkey: redis.Redis = Depends(get_valkey), ) -> dict[str, Any]: - """Return persisted content evidence; never derive or invent buyer copy.""" + """Return persisted content evidence; never derive or invent reader-facing copy.""" await _load_visible_post(post_id, account, pool) queue_event: tuple[str, str] | None = None async with pool.acquire() as conn: @@ -1475,7 +1505,7 @@ async def read_post_content( """ select unit.unit_index, unit.unit_kind_code, unit.unit_label, unit.unit_text, coalesce(structure.indent_level, 0) as indent_level, - structure.decision_source_code, structure.confidence, + structure.decision_source_code, structure.structure_confidence, structure.evidence_text from post_content_unit unit left join post_content_unit_structure structure @@ -1520,7 +1550,7 @@ async def read_post_content( rows = await conn.fetch( """ select image.post_content_image_id, unit.unit_index, image.mime_type, image.description_status_code, - image.extracted_text, image.caption, + image.extracted_text, image.image_caption, coalesce( array_agg(tag.tag_text order by tag.tag_text) filter (where tag.tag_text is not null), @@ -1533,7 +1563,7 @@ async def read_post_content( on tag.post_content_image_id = image.post_content_image_id where unit.post_id = $1 group by image.post_content_image_id, unit.unit_index, image.mime_type, image.description_status_code, - image.extracted_text, image.caption + image.extracted_text, image.image_caption order by unit.unit_index """, post_id, @@ -1542,7 +1572,7 @@ async def read_post_content( """ select image.post_content_image_id, region.region_index, region.x_ratio, region.y_ratio, region.width_ratio, region.height_ratio, - region.description_status_code, region.extracted_text, region.caption, + region.description_status_code, region.extracted_text, region.image_caption, coalesce( array_agg(tag.tag_text order by tag.tag_text) filter (where tag.tag_text is not null), @@ -1556,7 +1586,7 @@ async def read_post_content( where image.post_content_image_id = any($1::uuid[]) group by image.post_content_image_id, region.region_index, region.x_ratio, region.y_ratio, region.width_ratio, region.height_ratio, - region.description_status_code, region.extracted_text, region.caption + region.description_status_code, region.extracted_text, region.image_caption order by image.post_content_image_id, region.region_index """, [row["post_content_image_id"] for row in rows], @@ -1578,7 +1608,7 @@ async def read_post_content( "height_ratio": row["height_ratio"], "status_code": row["description_status_code"], "extracted_text": row["extracted_text"], - "caption": row["caption"], + "caption": row["image_caption"], "tags": list(row["tags"] or []), } ) @@ -1592,7 +1622,7 @@ async def read_post_content( "unit_text": row["unit_text"], "indent_level": row["indent_level"], "indent_source_code": row["decision_source_code"] or "unresolved", - "indent_confidence": float(row["confidence"] or 0), + "indent_confidence": float(row["structure_confidence"] or 0), "indent_evidence": row["evidence_text"] or "", } for row in unit_rows @@ -1603,7 +1633,7 @@ async def read_post_content( "mime_type": row["mime_type"], "status_code": row["description_status_code"], "extracted_text": row["extracted_text"], - "caption": row["caption"], + "caption": row["image_caption"], "tags": list(row["tags"] or []), "regions": regions_by_image.get(str(row["post_content_image_id"]), []), } @@ -2574,7 +2604,7 @@ class ChatRequest(BaseModel): class GlobalAskRequest(BaseModel): - """JSON body for the buyer's source-grounded Global Ask Agent.""" + """JSON body for the reader's source-grounded Global Ask Agent.""" question: str @@ -2680,7 +2710,7 @@ async def ask_agent( account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), ) -> dict[str, Any]: - """Answer a buyer question from authorized post and graph evidence.""" + """Answer a reader question from authorized post and graph evidence.""" question = request.question.strip() if not question: raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "question is required") @@ -2742,7 +2772,7 @@ async def read_post_bookmark( await _load_visible_post(post_id, account, pool) async with pool.acquire() as conn: row = await conn.fetchrow( - "select 1 from bookmark where user_account_id = $1 and post_id = $2", + "select 1 from post_bookmark where user_account_id = $1 and post_id = $2", account.user_account_id, post_id, ) @@ -2761,7 +2791,7 @@ async def write_post_bookmark( if request.bookmarked: await conn.execute( """ - insert into bookmark (user_account_id, post_id) + insert into post_bookmark (user_account_id, post_id) values ($1, $2) on conflict (user_account_id, post_id) do nothing """, @@ -2770,7 +2800,7 @@ async def write_post_bookmark( ) else: await conn.execute( - "delete from bookmark where user_account_id = $1 and post_id = $2", + "delete from post_bookmark where user_account_id = $1 and post_id = $2", account.user_account_id, post_id, ) diff --git a/backend/app/post_chat_ingestion.py b/backend/app/post_chat_ingestion.py index 71c0f2053..29c5c998b 100644 --- a/backend/app/post_chat_ingestion.py +++ b/backend/app/post_chat_ingestion.py @@ -191,14 +191,14 @@ async def _semantic_facts_for_posts( || ' | evidence: ' || left(evidence_text, 500) || ' | ontology_iri: ' || ontology_iri || ' | extraction_method: ' || extraction_method - || ' | confidence: ' || confidence::text + || ' | confidence: ' || mention_confidence::text || ' [provenance=post_project_mention]' as fact from post_project_mention where post_id = any($1::uuid[]) union all select post_id::text as post_id, 'actor: ' || left(actor_name, 200) - || ' | responsibility: ' || left(responsibility, 500) + || ' | responsibility: ' || left(responsibility_text, 500) || coalesce(' | affiliation: ' || left(affiliated_organization_name, 200), '') || ' [provenance=post_summary_role]' as fact from post_summary_role diff --git a/backend/app/post_eligibility.py b/backend/app/post_eligibility.py index 41473d9da..48e3da9db 100644 --- a/backend/app/post_eligibility.py +++ b/backend/app/post_eligibility.py @@ -1,4 +1,4 @@ -"""Shared source-post eligibility SQL for buyer evidence reads.""" +"""Shared source-post eligibility SQL for reader-facing evidence reads.""" SOURCE_CONTEXT_COLUMNS = ( "source_author_code", diff --git a/backend/app/post_summary_ingestion.py b/backend/app/post_summary_ingestion.py index 7403185ed..841f83c98 100644 --- a/backend/app/post_summary_ingestion.py +++ b/backend/app/post_summary_ingestion.py @@ -89,7 +89,7 @@ async def fetch_persisted_summary( (ADR 0019 / 0027). This function does not join ``corporate_entity`` by ``entity_name``. Person chips read ``cataloged_person_id``. A stale row is returned only when ``allow_stale`` is explicit so a caller can - preserve buyer continuity without presenting old semantics as current. + preserve reader continuity without presenting old semantics as current. """ header = await conn.fetchrow( "select korean_summary, summary_contract_version " @@ -115,7 +115,7 @@ async def fetch_persisted_summary( ) roles = await conn.fetch( """ - select role.actor_name, role.responsibility, role.actor_type_code, + select role.actor_name, role.responsibility_text, role.actor_type_code, role.affiliated_organization_name, role.cataloged_team_id, role.cataloged_corporate_entity_id, @@ -128,7 +128,7 @@ async def fetch_persisted_summary( ) projects = await conn.fetch( """ - select project_key, project_name, evidence_text, confidence, ontology_iri, + select project_key, project_name, evidence_text, mention_confidence, ontology_iri, extraction_method from post_project_mention where post_id = $1 @@ -166,7 +166,7 @@ async def fetch_persisted_summary( payload_roles.append( { "actor_name": row["actor_name"], - "responsibility": row["responsibility"], + "responsibility": row["responsibility_text"], "actor_type_code": row["actor_type_code"], "affiliated_organization_name": row["affiliated_organization_name"], "catalog_node_id": catalog_node_id, @@ -207,7 +207,7 @@ async def fetch_persisted_summary( "project_key": row["project_key"], "project_name": row["project_name"], "evidence": row["evidence_text"], - "confidence": float(row["confidence"]), + "confidence": float(row["mention_confidence"]), "ontology_iri": row["ontology_iri"], "extraction_method": row["extraction_method"], } @@ -338,13 +338,13 @@ async def _replace_summary_projection( await conn.execute( """ insert into post_project_mention - (post_id, project_key, project_name, evidence_text, confidence, + (post_id, project_key, project_name, evidence_text, mention_confidence, ontology_iri, extraction_method) values ($1, $2, $3, $4, $5, $6, 'contextual_orchestrator_semantic') on conflict (post_id, project_key) do update set project_name = excluded.project_name, evidence_text = excluded.evidence_text, - confidence = excluded.confidence, + mention_confidence = excluded.mention_confidence, ontology_iri = excluded.ontology_iri, extraction_method = excluded.extraction_method """, @@ -415,7 +415,7 @@ async def _replace_summary_projection( ) await conn.execute( "insert into post_summary_role " - "(post_id, actor_name, responsibility, actor_type_code, " + "(post_id, actor_name, responsibility_text, actor_type_code, " "affiliated_organization_name, cataloged_team_id, " "cataloged_corporate_entity_id, cataloged_person_id) values " "($1, $2, $3, $4, $5, $6, $7, $8)", diff --git a/backend/app/ranking_ingestion.py b/backend/app/ranking_ingestion.py index 512b71273..71cd66e57 100644 --- a/backend/app/ranking_ingestion.py +++ b/backend/app/ranking_ingestion.py @@ -18,7 +18,7 @@ async def load_visible_ranking_posts( conn: "asyncpg.Connection", can_see_post: Callable[[Mapping[str, Any]], bool], ) -> list[dict[str, Any]]: - """Read ``source_post`` rows the buyer may rank.""" + """Read ``source_post`` rows the reader may rank.""" posts = await conn.fetch( "select post_id, post_title, created_at, visibility_code, " "corporate_entity_id from source_post" diff --git a/backend/app/report_ingestion.py b/backend/app/report_ingestion.py index 50614b0ad..5edfff64d 100644 --- a/backend/app/report_ingestion.py +++ b/backend/app/report_ingestion.py @@ -129,7 +129,7 @@ def grouping_value(kind: str, row: asyncpg.Record) -> str | None: from post_evaluation_response e join source_post p on p.post_id = e.post_id join post_project_mention mention on mention.post_id = p.post_id - and mention.confidence >= 0.7 + and mention.mention_confidence >= 0.7 where e.rubric_version = $1 and to_char(p.created_at at time zone 'UTC', 'IYYY-"W"IW') = $2 """ @@ -154,7 +154,7 @@ def grouping_value(kind: str, row: asyncpg.Record) -> str | None: from post_evaluation_response e join source_post p on p.post_id = e.post_id join post_project_mention mention on mention.post_id = p.post_id - and mention.confidence >= 0.7 + and mention.mention_confidence >= 0.7 where e.rubric_version = $1 and to_char(p.created_at at time zone 'UTC', 'YYYY-MM') = $2 """ @@ -256,7 +256,7 @@ async def load_shared_item_bank( return None items = await conn.fetch( """ - select item_code, item_index, slope, cat_params + select item_code, item_index, item_slope, cat_params from report_item_parameter where grouping_kind = $1 and grouping_key = $2 and period_code = $3 and rubric_version = $4 @@ -272,7 +272,7 @@ async def load_shared_item_bank( return ItemBank( model=str(header["selected_model"]), item_codes=tuple(str(row["item_code"]) for row in items), - slope=tuple(float(row["slope"]) for row in items), + slope=tuple(float(row["item_slope"]) for row in items), cat_params=tuple(tuple(float(value) for value in row["cat_params"]) for row in items), source_period_code=str(header["period_code"]), ) @@ -319,7 +319,7 @@ async def load_anchor_item_bank( return None items = await conn.fetch( """ - select item_code, item_index, slope, cat_params + select item_code, item_index, item_slope, cat_params from report_item_parameter where grouping_kind = $1 and grouping_key = $2 and period_code = $3 and rubric_version = $4 @@ -336,7 +336,7 @@ async def load_anchor_item_bank( ItemBank( model=str(header["selected_model"]), item_codes=tuple(str(row["item_code"]) for row in items), - slope=tuple(float(row["slope"]) for row in items), + slope=tuple(float(row["item_slope"]) for row in items), cat_params=tuple(tuple(float(value) for value in row["cat_params"]) for row in items), source_period_code=str(header["period_code"]), ), @@ -410,7 +410,7 @@ async def persist_period_report( """ insert into report_item_parameter ( grouping_kind, grouping_key, period_code, rubric_version, - item_code, item_index, slope, cat_params + item_code, item_index, item_slope, cat_params ) values ($1,$2,$3,$4,$5,$6,$7,$8) """, grouping_kind, @@ -427,7 +427,7 @@ async def persist_period_report( """ insert into report_item_information ( grouping_kind, grouping_key, period_code, rubric_version, - item_code, item_rank, information + item_code, item_rank, information_value ) values ($1,$2,$3,$4,$5,$6,$7) """, grouping_kind, @@ -588,7 +588,7 @@ async def fetch_period_reports( ) selected = await conn.fetch( """ - select grouping_key, item_code, item_rank, information + select grouping_key, item_code, item_rank, information_value from report_item_information where grouping_kind = $1 and period_code = $2 and rubric_version = $3 order by grouping_key, item_rank @@ -686,7 +686,7 @@ async def fetch_period_reports( { "item_code": str(row["item_code"]), "rank": int(row["item_rank"]), - "information": float(row["information"]), + "information": float(row["information_value"]), } for row in selected_by_group.get(header["grouping_key"], []) ], @@ -742,7 +742,7 @@ async def list_period_report_summaries( ) top_items = await conn.fetch( """ - select grouping_key, period_code, item_code, information + select grouping_key, period_code, item_code, information_value from report_item_information where grouping_kind = $1 and rubric_version = $2 and item_rank = 1 """, @@ -777,7 +777,7 @@ async def list_period_report_summaries( "selected_item_information": ( None if top_by_key.get((row["grouping_key"], row["period_code"])) is None - else float(top_by_key[(row["grouping_key"], row["period_code"])]["information"]) + else float(top_by_key[(row["grouping_key"], row["period_code"])]["information_value"]) ), "members": [ { @@ -818,7 +818,7 @@ async def resolve_grouping_label(conn: asyncpg.Connection, grouping_kind: str, g elif grouping_kind == "project": row = await conn.fetchrow( "select project_name from post_project_mention " - "where project_key = $1 order by confidence desc, project_name limit 1", + "where project_key = $1 order by mention_confidence desc, project_name limit 1", grouping_key, ) if row is not None: diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 8d3623041..c50a34ae2 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -113,6 +113,16 @@ / "migrations" / "0102_project_bound_summary_event.sql" ) +_TENANT_SETTINGS_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0103_tenant_settings.sql" +) +_IDENTIFIER_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0104_two_word_database_identifiers.sql" +) def _postgres_available() -> bool: @@ -226,6 +236,8 @@ def seeded_db(demo_analyst_token): cur.execute(_MAJOR_EVENT_ACTION_MIGRATION.read_text()) cur.execute(_PROJECT_BOUND_ACTION_MIGRATION.read_text()) cur.execute(_PROJECT_BOUND_EVENT_MIGRATION.read_text()) + cur.execute(_TENANT_SETTINGS_MIGRATION.read_text()) + cur.execute(_IDENTIFIER_MIGRATION.read_text()) cur.execute( "insert into common_lookup_value (lookup_category, lookup_code, lookup_label) values " "('corporate_entity_level', 'group', 'Group'), " @@ -661,7 +673,7 @@ def test_create_analysis_run_records_pending_without_inventing_a_score( json={ "run_kind_code": "analysis_run_lineage", "corporate_entity_id": seeded_db["own_corp_id"], - "idempotency_key": "buyer-create-2026-w02", + "idempotency_key": "run-create-2026-w02", }, ) assert created.status_code == 201 @@ -682,7 +694,7 @@ def test_create_analysis_run_records_pending_without_inventing_a_score( json={ "run_kind_code": "analysis_run_lineage", "corporate_entity_id": seeded_db["own_corp_id"], - "idempotency_key": "buyer-create-2026-w02", + "idempotency_key": "run-create-2026-w02", }, ) assert replay.status_code == 201 @@ -694,7 +706,7 @@ def test_create_analysis_run_records_pending_without_inventing_a_score( json={ "run_kind_code": "analysis_run_tepp", "corporate_entity_id": seeded_db["own_corp_id"], - "idempotency_key": "buyer-create-tepp", + "idempotency_key": "run-create-tepp", }, ) assert tepp.status_code == 422 @@ -707,7 +719,7 @@ def test_create_analysis_run_records_pending_without_inventing_a_score( json={ "run_kind_code": "analysis_run_report", "corporate_entity_id": seeded_db["own_corp_id"], - "idempotency_key": "buyer-create-report", + "idempotency_key": "run-create-report", }, ) assert report.status_code == 422 @@ -720,7 +732,7 @@ def test_create_analysis_run_records_pending_without_inventing_a_score( "run_kind_code": "analysis_run_lineage", "corporate_entity_id": seeded_db["own_corp_id"], "knowledge_cutoff": "2026-01-01T00:00:00Z", - "idempotency_key": "buyer-create-2026-w02", + "idempotency_key": "run-create-2026-w02", }, ) assert conflict.status_code == 409 @@ -731,14 +743,14 @@ def test_create_analysis_run_records_pending_without_inventing_a_score( json={ "run_kind_code": "analysis_run_lineage", "corporate_entity_id": seeded_db["other_corp_id"], - "idempotency_key": "buyer-create-hidden-corp", + "idempotency_key": "run-create-hidden-corp", }, ) assert hidden.status_code == 404 unauthenticated = client.post( "/api/analysis-runs", - json={"idempotency_key": "buyer-create-unauthenticated"}, + json={"idempotency_key": "run-create-unauthenticated"}, ) assert unauthenticated.status_code == 401 @@ -781,7 +793,7 @@ def test_start_analysis_run_recovers_the_a100_fork( "run_kind_code": "analysis_run_lineage", "corporate_entity_id": seeded_db["own_corp_id"], "knowledge_cutoff": "2026-02-15T00:00:00Z", - "idempotency_key": "buyer-start-2026-w07", + "idempotency_key": "run-start-2026-w07", }, ) assert created.status_code == 201, created.text @@ -853,7 +865,7 @@ def test_start_analysis_run_recovers_the_a100_fork( "run_kind_code": "analysis_run_tepp", "corporate_entity_id": seeded_db["own_corp_id"], "knowledge_cutoff": "2026-02-15T00:00:00Z", - "idempotency_key": "buyer-start-tepp-2026-w07", + "idempotency_key": "run-start-tepp-2026-w07", }, ) assert tepp_create.status_code == 422 @@ -887,7 +899,7 @@ def test_start_analysis_run_recovers_the_a100_fork( requested_by_account_id, knowledge_cutoff, configuration_schema_version, configuration_sha256, code_revision_sha, requested_at) - values (%s, 'analysis_run_tepp', 'buyer-start-tepp-seeded', + values (%s, 'analysis_run_tepp', 'run-start-tepp-seeded', %s, '2026-02-15T00:00:00Z', 'tepp-run-v1', %s, %s, '2026-02-15T12:30:00Z') returning analysis_run_id @@ -956,7 +968,7 @@ def test_start_analysis_run_recovers_the_a100_fork( requested_by_account_id, knowledge_cutoff, configuration_schema_version, configuration_sha256, code_revision_sha, requested_at) - values (%s, 'analysis_run_report', 'buyer-start-report', + values (%s, 'analysis_run_report', 'run-start-report', %s, '2026-01-12T12:00:00Z', 'lineage-run-v1', %s, %s, '2026-01-12T12:30:00Z') returning analysis_run_id @@ -999,7 +1011,7 @@ def test_start_analysis_run_recovers_the_a100_fork( requested_by_account_id, knowledge_cutoff, configuration_schema_version, configuration_sha256, code_revision_sha, requested_at) - values (%s, 'analysis_run_lineage', 'buyer-start-running', + values (%s, 'analysis_run_lineage', 'run-start-running', %s, '2026-01-12T12:00:00Z', 'lineage-run-v1', %s, %s, '2026-01-12T12:30:00Z') returning analysis_run_id @@ -1072,7 +1084,7 @@ def test_start_analysis_run_recovers_the_a100_fork( requested_by_account_id, knowledge_cutoff, configuration_schema_version, configuration_sha256, code_revision_sha, requested_at) - values (%s, 'analysis_run_lineage', 'buyer-start-outbox-resume', + values (%s, 'analysis_run_lineage', 'run-start-outbox-resume', %s, '2026-02-15T00:00:00Z', 'lineage-run-v1', %s, %s, '2026-02-15T12:30:00Z') returning analysis_run_id @@ -1127,7 +1139,30 @@ def test_start_analysis_run_recovers_the_a100_fork( assert "Pricing renegotiation: revised quote sent" in children -def test_me_reflects_the_authenticated_account(client, demo_analyst_token) -> None: +def test_me_reflects_the_authenticated_account(client, demo_analyst_token, seeded_db) -> None: + admin_conn = psycopg2.connect(seeded_db["dsn"]) + try: + with admin_conn.cursor() as cur: + cur.execute( + "select user_account_id from account_affiliation where corporate_entity_id = %s", + (seeded_db["own_corp_id"],), + ) + account_id = cur.fetchone()[0] + cur.execute( + "insert into process_unit (corporate_entity_id, process_unit_code, process_unit_name) " + "values (%s, 'TEST-PU', 'Test PU') returning process_unit_id", + (seeded_db["own_corp_id"],), + ) + process_unit_id = cur.fetchone()[0] + cur.execute( + "update account_affiliation set process_unit_id = %s " + "where user_account_id = %s and corporate_entity_id = %s", + (process_unit_id, account_id, seeded_db["own_corp_id"]), + ) + admin_conn.commit() + finally: + admin_conn.close() + response = client.get("/api/me", headers={"Authorization": f"Bearer {demo_analyst_token}"}) assert response.status_code == 200 body = response.json() @@ -1136,6 +1171,62 @@ def test_me_reflects_the_authenticated_account(client, demo_analyst_token) -> No assert any( entity["entity_name"] == "Test Corp" for entity in body["corporate_entities"] ) + affiliation = next( + row + for row in body["account_affiliations"] + if row["corporate_entity_id"] == seeded_db["own_corp_id"] + ) + assert affiliation == { + "corporate_entity_id": seeded_db["own_corp_id"], + "corporate_entity_code": "TEST-CORP", + "entity_name": "Test Corp", + "process_unit_id": affiliation["process_unit_id"], + "process_unit_code": "TEST-PU", + "process_unit_name": "Test PU", + } + + +def test_healthz_is_a_public_liveness_probe(client) -> None: + """Regression test: a dangling ``@app.get("/healthz")`` decorator once + attached to ``read_tenant_settings`` instead of the liveness probe, + requiring auth on ``/healthz`` and leaving the real ``healthz()`` + handler undecorated. Docker's own healthcheck (docker-compose.yml) + calls this route unauthenticated, so any auth requirement here breaks + container health and cascades into the whole compose dependency graph. + """ + response = client.get("/healthz") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + +def test_settings_get_requires_auth_and_returns_brand_name(client, demo_analyst_token) -> None: + unauthenticated = client.get("/api/settings") + assert unauthenticated.status_code == 401 + + response = client.get("/api/settings", headers={"Authorization": f"Bearer {demo_analyst_token}"}) + assert response.status_code == 200 + assert response.json()["brandName"] + + +def test_settings_patch_requires_post_admin(client, demo_analyst_token, seeded_db) -> None: + denied = client.patch( + "/api/settings", + json={"brandName": "Should not apply"}, + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert denied.status_code == 403 + + _grant_post_admin(seeded_db["dsn"]) + allowed = client.patch( + "/api/settings", + json={"brandName": "LineageWeave Demo"}, + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert allowed.status_code == 200 + assert allowed.json() == {"brandName": "LineageWeave Demo"} + + confirm = client.get("/api/settings", headers={"Authorization": f"Bearer {demo_analyst_token}"}) + assert confirm.json() == {"brandName": "LineageWeave Demo"} def test_customer_master_returns_authorized_catalog_contract(client, demo_analyst_token, seeded_db) -> None: @@ -1164,7 +1255,7 @@ def test_customer_master_returns_authorized_catalog_contract(client, demo_analys ) cur.execute( "insert into post_summary_role " - "(post_id, actor_name, responsibility, actor_type_code, affiliated_organization_name, cataloged_person_id) " + "(post_id, actor_name, responsibility_text, actor_type_code, affiliated_organization_name, cataloged_person_id) " "values (%s, %s, %s, %s, %s, %s)", ( seeded_db["public_post_id"], @@ -1435,7 +1526,7 @@ def test_post_detail_exposes_explicit_and_semantic_project_evidence( cur.execute( """ insert into post_project_mention - (post_id, project_key, project_name, evidence_text, confidence, + (post_id, project_key, project_name, evidence_text, mention_confidence, ontology_iri, extraction_method) values (%s, %s, %s, %s, %s, %s, %s) """, @@ -1541,7 +1632,7 @@ def test_persisted_summary_is_returned_without_an_llm(client, demo_analyst_token ) cur.execute( "insert into post_summary_role " - "(post_id, actor_name, responsibility, actor_type_code, affiliated_organization_name) " + "(post_id, actor_name, responsibility_text, actor_type_code, affiliated_organization_name) " "values (%s, 'Ada West', '후속 연락', 'prov_person', 'Demo Corp')", (seeded_db["public_post_id"],), ) @@ -1568,7 +1659,7 @@ def test_persisted_summary_is_returned_without_an_llm(client, demo_analyst_token def test_stale_summary_is_returned_labeled_when_orchestrator_is_unavailable( client, demo_analyst_token, seeded_db ) -> None: - """A legacy saved summary preserves buyer continuity with an explicit label.""" + """A legacy saved summary preserves reader continuity with an explicit label.""" os.environ.pop("ORCHESTRATOR_BASE_URL", None) os.environ.pop("ORCHESTRATOR_API_KEY", None) admin_conn = psycopg2.connect(seeded_db["dsn"]) @@ -2141,7 +2232,7 @@ def test_related_keymen_includes_chronological_role_history(client, demo_analyst ) cur.execute( "insert into post_summary_role " - "(post_id, actor_name, responsibility, actor_type_code, affiliated_organization_name, cataloged_person_id) " + "(post_id, actor_name, responsibility_text, actor_type_code, affiliated_organization_name, cataloged_person_id) " "values (%s, %s, %s, %s, %s, %s)", ( seeded_db["own_private_post_id"], @@ -2154,7 +2245,7 @@ def test_related_keymen_includes_chronological_role_history(client, demo_analyst ) cur.execute( "insert into post_summary_role " - "(post_id, actor_name, responsibility, actor_type_code, affiliated_organization_name, cataloged_person_id) " + "(post_id, actor_name, responsibility_text, actor_type_code, affiliated_organization_name, cataloged_person_id) " "values (%s, %s, %s, %s, %s, %s)", ( seeded_db["public_post_id"], @@ -4777,7 +4868,7 @@ def test_seed_period_report_member_click_lands_on_decorated_fixture( client, demo_analyst_token, seeded_db ) -> None: """The first W02 report member must already have Event Lineage, - Keyman, and evaluation -- otherwise the buyer click opens a dummy + Keyman, and evaluation -- otherwise the reader click opens a dummy high/low band row. """ from lineageweave.fixtures import fixture_thread_cast, fixture_titles_in_iso_week diff --git a/docker-compose.yml b/docker-compose.yml index 96ec0b89a..c48783a3b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -112,7 +112,7 @@ services: # Gateway credentials and URL are supplied only by env_file (${HOME}/.env). # Do not repeat them under environment:, where Compose interpolation can # overwrite env_file values with an empty host-shell value. - # The upstream default remains 64 KiB for ordinary text APIs. Buyer + # The upstream default remains 64 KiB for ordinary text APIs. Post # image blocks are base64 data URIs, so the multimodal boundary gets an # explicit bounded 8 MiB limit rather than an unbounded request size. CONTEXTUAL_ORCHESTRATOR_MAX_BODY_BYTES: ${CONTEXTUAL_ORCHESTRATOR_MAX_BODY_BYTES:-8388608} diff --git a/docker/keycloak/Dockerfile b/docker/keycloak/Dockerfile index 69ebd26c5..049b0e975 100644 --- a/docker/keycloak/Dockerfile +++ b/docker/keycloak/Dockerfile @@ -1,5 +1,6 @@ FROM quay.io/keycloak/keycloak:26.0@sha256:09a381c715ab0b111835b70f2905955274843a219c6f27efb348e4d9f4086858 COPY realm-export.json /opt/keycloak/data/import/realm-export.json +COPY themes/lineageweave /opt/keycloak/themes/lineageweave # Official image's default non-root account (uid 1000). Declared so the # Dockerfile itself satisfies DS-0002 (explicit non-root USER). USER 1000 diff --git a/docker/keycloak/realm-export.json b/docker/keycloak/realm-export.json index be9826ea4..4f1920575 100644 --- a/docker/keycloak/realm-export.json +++ b/docker/keycloak/realm-export.json @@ -1,6 +1,8 @@ { "realm": "lineageweave-demo", "enabled": true, + "displayName": "LineageWeave", + "loginTheme": "lineageweave", "sslRequired": "none", "registrationAllowed": false, "accessTokenLifespan": 900, diff --git a/docker/keycloak/themes/lineageweave/login/resources/css/lineageweave.css b/docker/keycloak/themes/lineageweave/login/resources/css/lineageweave.css new file mode 100644 index 000000000..0ef285933 --- /dev/null +++ b/docker/keycloak/themes/lineageweave/login/resources/css/lineageweave.css @@ -0,0 +1,77 @@ +/* + * LineageWeave brand overrides for the stock keycloak.v2 login theme. + * Loaded after the parent's css/styles.css (see theme.properties), so + * these rules win the cascade without needing !important. + * Values mirror frontend/src/styles/tokens.css so the OIDC redirect stays + * visually continuous with the app's own pre-redirect login card + * (UI/UX Standard Guide Ver.3.0 SS3.2). + */ + +.login-pf body { + background: #fff; + font-family: + "Noto Sans KR", + "Noto Sans", + "Nanum Gothic", + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + Roboto, + sans-serif; +} + +.pf-v5-c-login, +.pf-v5-c-login__main { + font-family: inherit; +} + +/* + * Parent styles.css sets #kc-header-wrapper's color with !important + * (a light/white token meant to sit on the dark polygon background we + * just removed). Match it with !important so the brand text is legible + * on our white background instead of rendering white-on-white. + */ +#kc-header-wrapper { + color: #034ea2 !important; + font-weight: 700; + text-transform: none; + letter-spacing: normal; +} + +.pf-v5-c-login__main-body, +.card-pf { + background: #fff; + border: 1px solid #e5e4e7; + border-radius: 12px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08); +} + +.pf-v5-c-button.pf-m-primary { + background-color: #034ea2; + border-color: #034ea2; +} + +.pf-v5-c-button.pf-m-primary:hover, +.pf-v5-c-button.pf-m-primary:focus { + background-color: #0047bb; + border-color: #0047bb; +} + +a, +a:visited { + color: #034ea2; +} + +div.kc-logo-text { + background-image: none; + height: auto; + width: auto; +} + +div.kc-logo-text span { + display: inline; + color: #034ea2; + font-weight: 700; + font-size: 1.5rem; +} diff --git a/docker/keycloak/themes/lineageweave/login/theme.properties b/docker/keycloak/themes/lineageweave/login/theme.properties new file mode 100644 index 000000000..442ab2fde --- /dev/null +++ b/docker/keycloak/themes/lineageweave/login/theme.properties @@ -0,0 +1,4 @@ +parent=keycloak.v2 +import=common/keycloak + +styles=css/styles.css css/lineageweave.css diff --git a/docker/postgres-init/migrate.sh b/docker/postgres-init/migrate.sh index f329117d6..3a16458d4 100644 --- a/docker/postgres-init/migrate.sh +++ b/docker/postgres-init/migrate.sh @@ -18,7 +18,7 @@ for migration in /opt/lineageweave/migrations/*.sql; do migration_name=${migration##*/} case "$migration_name" in 0012_*|0013_*|0014_*|0015_*|0016_*|0017_*|0018_*|0019_*|0020_*|0021_*|0022_*|0023_*|0024_*|0025_*|0026_*|0027_*|0028_*|0029_*|0030_*|0031_*|0032_*|0033_*|0034_*|0035_*|0036_*|0037_*|0038_*|0039_*|0040_*|0041_*|0042_*|0043_*|0044_*|0045_*|0046_*|0047_*|0048_*|0049_*|0050_*) ;; - 0060_*|0100_*|0101_*|0102_*) ;; + 0060_*|0100_*|0101_*|0102_*|0103_*|0104_*) ;; *) continue ;; esac printf 'Applying %s\n' "$migration_name" diff --git a/docs/adr/0063-third-normal-form-bookmarks.md b/docs/adr/0063-third-normal-form-bookmarks.md index 4ba2f5b4f..95a83fea7 100644 --- a/docs/adr/0063-third-normal-form-bookmarks.md +++ b/docs/adr/0063-third-normal-form-bookmarks.md @@ -14,7 +14,7 @@ attributes. ## Decision -Use a `bookmark` table with: +Use a `post_bookmark` table with: - `bookmark_id` as the independent surrogate primary key; - `user_account_id` as a foreign key to `user_account`; @@ -25,10 +25,15 @@ Use a `bookmark` table with: The composite account/post pair is therefore a business invariant only, never the primary identity of the bookmark entity. No display name, post title, or -authorization scope is duplicated in `bookmark`; those values remain in their +authorization scope is duplicated in `post_bookmark`; those values remain in their normalized source tables. Every read and write still performs the normal visible-post ABAC check for the requesting account. +The historical migration introduced this entity as `bookmark`; ADR 0120 +renames the persistent relation to `post_bookmark` to enforce the repository's +two-word database-identifier rule. The entity and authorization decision stay +unchanged. + ## Consequences - A bookmark has a stable identifier and remains in third normal form: every diff --git a/docs/adr/0105-mathematical-script-semantic-normalization.md b/docs/adr/0105-mathematical-script-semantic-normalization.md new file mode 100644 index 000000000..839c3c98c --- /dev/null +++ b/docs/adr/0105-mathematical-script-semantic-normalization.md @@ -0,0 +1,29 @@ +# ADR 0105: Preserve explicit metric scripts in semantic text + +- Status: Accepted +- Date: 2026-08-21 + +## Context + +Source posts may encode a metric unit such as `m3` or an indexed +quantity such as `m3`. Removing the script element loses searchable +and buyer-visible mathematical meaning, while treating every numeric +superscript as mathematics would break the existing footnote contract. + +## Decision + +1. Preserve the original source body unchanged. +2. In derived semantic text, normalize only an explicit bounded metric base + (`m`, `cm`, `mm`, `km`, or `kg`, optionally preceded by a number) followed + by one-to-three numeric `sup` or `sub` elements into Unicode + superscript/subscript digits. For example, `5m3` becomes `5m³`. +3. Leave ordinary numeric superscripts on prose under the existing footnote + role contract. +4. Apply the same normalization in backend chunks and frontend rendering. + +## Consequences + +Metric exponents remain searchable and readable without inventing formula +semantics. Arbitrary mathematical markup beyond this bounded case remains an +explicit open gap and must be covered by a later ADR and fixture before being +normalized. diff --git a/docs/adr/0119-retire-buyer-terminology.md b/docs/adr/0119-retire-buyer-terminology.md new file mode 100644 index 000000000..4124e1e0a --- /dev/null +++ b/docs/adr/0119-retire-buyer-terminology.md @@ -0,0 +1,56 @@ +# ADR 0119: Retire "Buyer" as the reader-facing terminology + +**Status:** Accepted +**Date:** 2026-08-21 + +**Context:** ADR 0037 named the four-destination frontend shell the "Buyer +GNB" and the term spread into component names (`BuyerNav`, +`BuyerDestination`), CSS classes (`.buyer-gnb`), i18n keys ("Buyer +navigation"), a visible legend label ("BUYER EVIDENCE"), Python identifiers +(`_buyer_evidence_kind`), and prose across `AGENTS.md`, `ARCHITECTURE.md`, +and docstrings. LineageWeave has no explicit buyer actor — it is an internal +analyst/marketing-intelligence workspace, not a storefront with a buyer +role. "Buyer" was a leftover label from early drafting, not a modeled +domain entity, and reads as confusing or inaccurate to anyone reading the +code or product surface. + +**Decision:** +1. Rename the frontend navigation shell: `BuyerNav` → `WorkspaceNav`, + `BuyerDestination` → `WorkspaceDestination`, `.buyer-gnb*` CSS → + `.workspace-gnb*`, `.buyer-destination*` → `.workspace-destination*`, + the "Buyer navigation" i18n key/aria-label → "Workspace navigation", and + the `#mobile-buyer-navigation` id → `#mobile-workspace-navigation`. +2. Rename the Event Lineage legend label "BUYER EVIDENCE" → "LINEAGE + EVIDENCE". +3. Rename backend/Python identifiers that described the same concept: + `_buyer_evidence_kind` → `_cited_evidence_kind`, + `_buyer_evidence_text` → `_cited_evidence_text`. +4. Replace prose that referred to "the buyer" as the person reading the + product with "the reader" (docstrings, comments, `AGENTS.md`, + `ARCHITECTURE.md`, living docs under `docs/`) or with "workspace" where + the prose named the navigation shell itself. +5. Do not rewrite historical ADRs (0002–0118) or `CHANGELOG.md` / + `CHANGELOG.d/*.md` entries — those are point-in-time records of the + decisions and releases made under the terminology that existed then. + This ADR documents the rename going forward; historical documents keep + their original wording for an accurate record. +6. Leave "buyer" where it appears as ordinary English inside simulated + post/table content (`lineageweave/fixtures.py`, + `tests/test_chunking.py`) — that is domain content a real sales note + could plausibly contain, not this project's own naming. + +**Consequences:** +- No source, test, or living-doc identifier or user-facing string uses + "Buyer" going forward; `grep -ri buyer` outside historical ADRs, + `CHANGELOG*`, and fixture/test content returns nothing. +- Historical ADRs and changelog entries remain internally consistent with + the PRs they describe; readers encountering "Buyer GNB" in ADR 0037 or + CHANGELOG 2.13.0 know it is the old name for what this ADR renames. +- Component/file rename (`BuyerNav.tsx` → `WorkspaceNav.tsx`) is a breaking + change for any external Storybook story or import path that referenced + the old name; none exist outside this repo at the time of this ADR. + +**References:** +- ADR 0037 (Buyer GNB and product-facing frontend surface) — superseded + terminology only, decision content unchanged. +- ADR 0118 (UI·UX Standard Guide Ver.3.0 Design Overhaul) diff --git a/docs/adr/0120-two-word-database-identifiers.md b/docs/adr/0120-two-word-database-identifiers.md new file mode 100644 index 000000000..1849fe1c1 --- /dev/null +++ b/docs/adr/0120-two-word-database-identifiers.md @@ -0,0 +1,58 @@ +# ADR 0120: Normalize persistent database identifiers to two-word snake_case + +- Status: Accepted +- Date: 2026-08-21 +- Supersedes: the single-token naming exception in ADR 0063 and the + single-token column names inherited by the analysis/report/content slices + +## Context + +The product standard requires every persistent database object name to use at +least two lowercase `snake_case` words. The live schema still contains the +legacy `bookmark` relation and several single-token columns. Keeping those +names would make the database itself contradict the current product contract, +even though the surrounding application and ADRs describe a normalized model. + +## Decision + +Migration `0104_two_word_database_identifiers.sql` renames only persistent +database identifiers; it does not change payload semantics or public JSON +field names: + +| Existing identifier | Canonical identifier | +| --- | --- | +| `bookmark` | `post_bookmark` | +| `analysis_run_status_event.retryable` | `analysis_run_status_event.is_retryable` | +| `post_content_image.caption` | `post_content_image.image_caption` | +| `post_content_image_region.caption` | `post_content_image_region.image_caption` | +| `post_content_unit_structure.confidence` | `post_content_unit_structure.structure_confidence` | +| `post_project_mention.confidence` | `post_project_mention.mention_confidence` | +| `post_summary_role.responsibility` | `post_summary_role.responsibility_text` | +| `report_item_information.information` | `report_item_information.information_value` | +| `report_item_parameter.slope` | `report_item_parameter.item_slope` | +| `tenant_settings.id` | `tenant_settings.tenant_settings_id` | + +The `analysis_run_current_status` view is recreated with +`is_retryable`. Application-facing JSON continues to use established names +such as `information`, `caption`, and `responsibility`; those are translation +boundaries, not persistent database identifiers. The migration is idempotent +for fresh and already-initialized Compose volumes and has a matching rollback. + +ADR 0063 remains the source of the bookmark entity's third-normal-form and +authorization decisions; this ADR changes only its table identifier. + +## Consequences + +- Schema audits can enforce the two-word naming rule without exceptions. +- SQL, migrations, and database integration tests must use the canonical names. +- Public API compatibility is preserved at the application serialization + boundary. +- Historical migration files remain immutable; replay reaches the canonical + schema through the additive rename migration. + +## Verification + +Acceptance requires applying the migration to a real PostgreSQL volume, +replaying it twice, checking that no public table/view/column violates the +two-word rule, and exercising bookmark, image evidence, summaries, reports, +tenant settings, and analysis-run status queries. diff --git a/docs/adr/0121-mhtml-source-body-resolution.md b/docs/adr/0121-mhtml-source-body-resolution.md new file mode 100644 index 000000000..ece8670d4 --- /dev/null +++ b/docs/adr/0121-mhtml-source-body-resolution.md @@ -0,0 +1,46 @@ +# ADR 0121: Resolve source bodies from governed MHTML artifacts + +## Status + +Accepted + +## Context + +The authorized export used by the private runtime contains post metadata and +MHTML artifact provenance, but some rows do not contain a body column. ADR +0056/0057 prohibit turning a title, summary, or inferred content into a +source body. The importer therefore needs an explicit, auditable path from a +source row to its separately stored MHTML artifact. + +## Decision + +- A body-bearing import uses exactly one of two mappings: an explicit body + column, or an artifact path column plus an artifact SHA-256 column and an + operator-supplied artifact root. +- Artifact paths must resolve beneath the configured root after symlink + resolution. Missing files, traversal outside the root, non-regular files, + malformed hashes, and digest mismatches fail preflight before any target + mutation. +- The resolver accepts RFC 2557 `multipart/related` messages and selects the + first leaf `text/html` part as the source body. It never falls back to a + title, plain-text summary, generated content, or an unrelated MIME part. +- Every non-excluded source row is resolved and validated before the target + scope or any `source_post` row is written. The artifact bytes remain + operator-local; only the source body and existing provenance-bearing target + fields are persisted. +- The source UUID/record-key mapping remains explicit and independent from + the artifact path. An artifact match cannot repair a missing immutable + source identity. + +## Consequences + +The private runtime can consume an authorized MHTML export without weakening +the fail-closed source-body contract. A missing or incorrect artifact is an +actionable import error rather than a silently incomplete post. The public +repository continues to contain only synthetic artifact fixtures. + +## References + +Palme, J., Hopmann, A., & Shelness, N. (1999). *MIME encapsulation of +aggregate documents, such as HTML (MHTML)* (RFC 2557). RFC Editor. +https://www.rfc-editor.org/rfc/rfc2557.html diff --git a/docs/adr/0124-operational-controlled-vocabulary-semantic-layer.md b/docs/adr/0124-operational-controlled-vocabulary-semantic-layer.md new file mode 100644 index 000000000..2b1ddbe08 --- /dev/null +++ b/docs/adr/0124-operational-controlled-vocabulary-semantic-layer.md @@ -0,0 +1,72 @@ +# ADR 0124: Model operational controlled vocabularies as SKOS concepts + +## Status + +Accepted + +## Context + +`common_lookup_value` already centralizes configuration-like values used by +post visibility, VOC type, permissions, and issue-ticket state. The existing +OWL/RDFS vocabulary modeled the knowledge-graph predicates and actor types, +but left these operational codes as untyped strings. That weakened the +semantic layer exactly where the product exposes public/private access, +VOC/VOM/VOP classification, RBAC permissions, and ticket/calendar workflow. + +## Decision + +Represent these four lookup categories as SKOS concept schemes in +`docs/ontology/lineageweave-kg.ttl`: + +- `post_visibility`: public and private post visibility concepts; +- `voc_type`: VOC, VOCC, VOCO, VOM, and VOP concepts; +- `permission`: post-read and post-admin concepts; +- `ticket_status`: open, in-progress, and closed concepts. + +The relational lookup code remains the stable `:lookupCode` annotation and +PostgreSQL remains the source of record. OWL object properties describe the +semantic use of a concept (`Post -> hasPostVisibility`, `Post -> hasVocType`, +`AccessRole -> hasPermission`, and `IssueTicket -> hasTicketStatus`) without +turning workflow state into a knowledge-graph edge predicate. + +The ontology round-trip test now includes these categories. A code is not +considered semantically available merely because it exists in the database; +it must resolve to a SKOS concept with a scheme and label. + +## Options considered + +1. Keep operational values as database-only strings. Rejected: this leaves + authorization, filtering, and workflow semantics outside the governed + ontology. +2. Add a second runtime taxonomy database. Rejected: it duplicates the + existing normalized lookup source and creates synchronization risk. +3. Publish the existing lookup values as SKOS concepts over the current + relational source. Selected: it adds machine-readable semantics without a + second store or a change to the API wire codes. + +## Consequences + +Positive: + +- public/private, VOC classification, RBAC permissions, and ticket state have + stable IRIs, labels, schemes, and domain/range semantics; +- drift between seeded lookup values and the published ontology fails tests; +- consumers can use SKOS alongside the existing OWL/RDFS and PROV-O profile. + +Negative: + +- adding a new operational lookup value now requires an ontology term and a + round-trip test update; +- the current profile still does not model every analysis-run and content + processing status as a semantic concept, so those remain explicitly tracked + gaps rather than being silently treated as complete. + +## References (APA 7th) + +Miles, A., & Bechhofer, S. (Eds.). (2009). *SKOS Simple Knowledge +Organization System reference*. World Wide Web Consortium. +https://www.w3.org/TR/skos-reference/ + +World Wide Web Consortium. (2017). *Shapes Constraint Language (SHACL)*. +https://www.w3.org/TR/shacl/ + diff --git a/docs/doctoring/DESIGN_TOKEN_REFERENCES.md b/docs/doctoring/DESIGN_TOKEN_REFERENCES.md index 717954045..7ae1afbdc 100644 --- a/docs/doctoring/DESIGN_TOKEN_REFERENCES.md +++ b/docs/doctoring/DESIGN_TOKEN_REFERENCES.md @@ -9,7 +9,7 @@ the Storybook inventory. | Source | Product implication | Implemented evidence | |---|---|---| | W3C Design Tokens Format Module 2025.10 | Name color, space, type, and radius once; consume those names from repeated objects. | `frontend/src/styles/tokens.css` defines `--color-*`, `--space-*`, `--size-control-min`, `--radius-chip`, `--radius-control`, `--radius-panel`, and `--font-*`. `CitationChip`, `PopupCloseButton`, `CutoffKnownBody`, and `LineageEntityPicker` read those names through `App.css`. | -| Storybook for React & Vite | Catalog repeated controls so a buyer can try the next click without reading `App.tsx`. | `frontend/src/components/*.stories.tsx` and `docs/storybook-inventory.md`. | +| Storybook for React & Vite | Catalog repeated controls so a reader can try the next click without reading `App.tsx`. | `frontend/src/components/*.stories.tsx` and `docs/storybook-inventory.md`. | | WCAG 2.2 | Give interactive controls programmatic names and announce an asynchronous evidence failure instead of leaving a perpetual loading state. | Component interaction tests exercise the named controls; `EvidencePanel` exposes its terminal failure with `role="alert"`. This is targeted evidence, not a claim of complete WCAG conformance. | ## APA 7th references diff --git a/docs/image-content-schema.md b/docs/image-content-schema.md index d8e490b80..7574db752 100644 --- a/docs/image-content-schema.md +++ b/docs/image-content-schema.md @@ -93,7 +93,7 @@ picture sat relative to the surrounding paragraphs." The demo popup does not yet read these tables. It splits the live `post_body` the same way `extract_base64_images` does: each `data:image/...;base64,...` payload becomes an `` at its original -character offset, and the surrounding HTML is shown as text. A buyer who +character offset, and the surrounding HTML is shown as text. A reader who opens the post sees the picture that sat between the paragraphs, not the base64 wall. Remote `src="https://..."` tags are stripped, never fetched. OCR, caption, and tag search still require the vision client on extract / diff --git a/docs/lineage-bi-research-notes.md b/docs/lineage-bi-research-notes.md index 94f99d73d..1f1c53a52 100644 --- a/docs/lineage-bi-research-notes.md +++ b/docs/lineage-bi-research-notes.md @@ -322,7 +322,7 @@ node, and an unresolved free-text affiliation is left as its own root rather than attached to the nearest name. VOC evidence is extractive, not abstractive. The post already carries a -closed `voc_type_code`; the buyer-felt gap was the missing span that +closed `voc_type_code`; the reader-felt gap was the missing span that justifies that label. `sentence_excerpts` returns the sentences that contain a classified organization name -- the ACE mention extent (Doddington et al., 2004) already used for Keyman -- and returns diff --git a/docs/ontology/lineageweave-kg.ttl b/docs/ontology/lineageweave-kg.ttl index d9156d332..c713b2a32 100644 --- a/docs/ontology/lineageweave-kg.ttl +++ b/docs/ontology/lineageweave-kg.ttl @@ -12,8 +12,9 @@ # # The formal OWL 2 / RDFS / SKOS vocabulary for the # `knowledge_graph_edge` table's node/edge types, the -# `entity_relationship_type` / `person_side` / `corporate_entity_level` -# controlled vocabularies in migrations/0001_initial_schema.sql, and +# `entity_relationship_type` / `person_side` / `corporate_entity_level`, +# `post_visibility` / `voc_type` / `permission` / `ticket_status` controlled +# vocabularies in migrations/0001_initial_schema.sql, and # `post_summary_role.actor_type_code` (migrations/0012). # # `knowledge_graph_edge` (source_node_type_code, source_node_id) -- @@ -22,6 +23,7 @@ # this file is the formal semantic layer over it -- PostgreSQL stays # the source of record. See docs/adr/0004-knowledge-graph-ontology.md # for the KG design rationale, docs/adr/0006-role-responsibility-agent-ontology.md +# and docs/adr/0124-operational-controlled-vocabulary-semantic-layer.md # for the R&R actor-type rationale (grounded in W3C PROV-O), and # tests/test_ontology.py for the round-trip check that every code below # actually exists as a common_lookup_value row, and vice versa. @@ -33,7 +35,7 @@ a owl:Ontology ; rdfs:label "LineageWeave Knowledge Graph Ontology" ; - rdfs:comment "Formal OWL 2 / RDFS / SKOS vocabulary for LineageWeave's knowledge_graph_edge node and edge types, entity_relationship_type, person_side, corporate_entity_level, and post_summary_role.actor_type_code controlled vocabularies." . + rdfs:comment "Formal OWL 2 / RDFS / SKOS vocabulary for LineageWeave's knowledge graph, operational controlled vocabularies, and post-summary actor types." . :lookupCode a owl:AnnotationProperty ; rdfs:label "lookup code" ; @@ -173,6 +175,114 @@ rdfs:label "has Voice-of-Supplier relationship" ; :lookupCode "rel_vos" . +################################################################# +# Operational controlled vocabularies +# +# These values are persisted in common_lookup_value and participate in +# authorization, filtering, and workflow state. They are SKOS concepts, +# not KG edge predicates: the object properties below make the semantic +# relationship explicit without pretending that a visibility or ticket +# status is a graph edge. +################################################################# + +:postVisibilityScheme a skos:ConceptScheme ; + rdfs:label "Post visibility scheme"@en . + +:PublicVisibility a skos:Concept ; + skos:inScheme :postVisibilityScheme ; + skos:prefLabel "Public"@en ; + :lookupCode "public" . + +:PrivateVisibility a skos:Concept ; + skos:inScheme :postVisibilityScheme ; + skos:prefLabel "Private"@en ; + :lookupCode "private" . + +:vocTypeScheme a skos:ConceptScheme ; + rdfs:label "Voice-of relationship type scheme"@en . + +:VoiceOfCustomer a skos:Concept ; + skos:inScheme :vocTypeScheme ; + skos:prefLabel "Voice of Customer"@en ; + :lookupCode "voc" . + +:VoiceOfCustomersCustomer a skos:Concept ; + skos:inScheme :vocTypeScheme ; + skos:prefLabel "Voice of Customer's Customer"@en ; + :lookupCode "vocc" . + +:VoiceOfCompetitor a skos:Concept ; + skos:inScheme :vocTypeScheme ; + skos:prefLabel "Voice of Competitor"@en ; + :lookupCode "voco" . + +:VoiceOfMarket a skos:Concept ; + skos:inScheme :vocTypeScheme ; + skos:prefLabel "Voice of Market"@en ; + :lookupCode "vom" . + +:VoiceOfPartner a skos:Concept ; + skos:inScheme :vocTypeScheme ; + skos:prefLabel "Voice of Partner"@en ; + :lookupCode "vop" . + +:permissionScheme a skos:ConceptScheme ; + rdfs:label "Application permission scheme"@en . + +:ReadPostsPermission a skos:Concept ; + skos:inScheme :permissionScheme ; + skos:prefLabel "Read posts"@en ; + :lookupCode "post_read" . + +:AdministerPostsPermission a skos:Concept ; + skos:inScheme :permissionScheme ; + skos:prefLabel "Administer posts"@en ; + :lookupCode "post_admin" . + +:ticketStatusScheme a skos:ConceptScheme ; + rdfs:label "Issue ticket status scheme"@en . + +:OpenTicketStatus a skos:Concept ; + skos:inScheme :ticketStatusScheme ; + skos:prefLabel "Open"@en ; + :lookupCode "open" . + +:InProgressTicketStatus a skos:Concept ; + skos:inScheme :ticketStatusScheme ; + skos:prefLabel "In progress"@en ; + :lookupCode "in_progress" . + +:ClosedTicketStatus a skos:Concept ; + skos:inScheme :ticketStatusScheme ; + skos:prefLabel "Closed"@en ; + :lookupCode "closed" . + +:IssueTicket a owl:Class ; + rdfs:label "Issue ticket"@en . + +:hasPostVisibility a owl:ObjectProperty ; + rdfs:domain :Post ; + rdfs:range skos:Concept ; + rdfs:label "has post visibility"@en . + +:hasVocType a owl:ObjectProperty ; + rdfs:domain :Post ; + rdfs:range skos:Concept ; + rdfs:label "has Voice-of type"@en . + +:AccessRole a owl:Class ; + rdfs:label "Access role"@en . + +:hasPermission a owl:ObjectProperty ; + rdfs:domain :AccessRole ; + rdfs:range skos:Concept ; + rdfs:label "has permission"@en . + +:hasTicketStatus a owl:ObjectProperty ; + rdfs:domain :IssueTicket ; + rdfs:range skos:Concept ; + rdfs:label "has ticket status"@en . + ################################################################# # SKOS -- corporate_entity_level (Group -> Company -> Plant) ################################################################# diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index c6618240e..863dbd055 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,26 +1,338 @@ # Product & Technical Gap Baseline -## 1. Known Parsing & Frontend Display Gaps -- **Footnote Parsing**: `post=00505695-3e61-1fd1-83c5-263f88a9e77a` fails to recognize footnotes (li/oi level errors). -- **Table Parsing**: `post=00505695-3e61-1fd1-80c6-86bb61c8ddc5` completely fails at parsing tables. -- **Indentation**: Incorrect indentation rendering in `post=00505695-7571-1fd1-83c3-d521b187ad5b` and `post=00505695-3e61-1fd1-83c0-497b3c1c455e`. -- **Image/Table OCR**: `post=00505695-7571-1fd1-83dd-3d22a61a5734` fails text recognition for tables inside images, markdown parsing fails, and image OCR description is too shallow for Ontology & Semantics. -- **Math/Superscripts**: `post=00505695-9612-1fe1-83a7-e30153323f25` fails to parse superscripts like m^3 properly. Needs strict Ontology grammar for math formulas. -- **Missing UI Elements**: DAG (Directed Acyclic Graph) view is currently missing from the frontend for `post=00505695-7571-1fd1-83c5-895ed333cdbc`. - -## 2. LLM Extraction & Knowledge Graph Gaps -- **Multiple Project Extraction**: (Resolved) LLM prompt updated to request key_events as objects with project_name, separating events correctly. -- **5W1H Missing**: (Resolved) LLM prompt updated to explicitly request 5W1H evidence items in the JSON output array. -- **R&R and Keyman Missing**: (Resolved) LLM prompt updated to explicitly instruct using actual stated names rather than collective titles. -- **Entity Resolution / Searxng**: Abbreviations like "한전" and "한국전력" are not mapped properly using Searxng and KG corroboration. -- **Meso-level Team Mapping**: (Resolved) Checked extraction logic; `team` mapping logic is present and correct, but LLM needed better explicit instruction which is covered by R&R resolution. -- **Base64 Image Omni-modal**: Current text-only embedding fails on images. Omni-modal LLM processing is required for images to capture layout, font size, colors, and spatial meaning. - -## 3. General Architecture Gaps -- **DB Architecture**: Ensure PostgreSQL is strictly used (no file DBs), 3rd normal form is maintained, and Hot Partitions are handled. DB locks must be managed (or use read/write replicas). -- **Zotero Integration**: Papers and standards referenced by TEPP must be synced via Local Zotero API (http://localhost:23119/api/) and cited using APA 7th edition in docstrings. -- **Testing**: We need actual testing of Psychometrics (Fast-MLSIRM parameter calibration, RMSE of estimates, Fixed-Item Parameter Calibration, CAT) against synthetic/demo data. -- **Security & Compliance**: PII masking cannot break the system. Need SOC 2 and CSAP compliance alternatives to blind PII masking. -- **LLM Orchestration**: Ensure ALL LLM calls route through `contextual-orchestrator` utilizing API keys (BYTEZ, NVIDIA, OPENROUTER, OPENAI) with auto model discovery and optimal reasoning effort allocation (Fugu/Conductor/TRINITY research). - -*This document is continuously updated by the hourly automated agent loop.* +> Audit scope: the current `fix/uiux-standard-guide-v3-postmerge` worktree, +> compared with `origin/main`, the UI/UX Standard Guide v3.0 supplied for this +> product, ADR 0118, the accepted TEPP PRD/contracts, and the +> contextual-orchestrator architecture. Real source identifiers are deliberately +> replaced with case labels; they must not enter repository artifacts. + +## 1. Exact-head evidence + +Audit anchor: the exact source state carried by this commit at 2026-08-21; +record the final PR head with `git rev-parse HEAD` during acceptance. + +Current source/test exact head observed before this documentation update: +`b897fabe18332081a64a9286e4ab580c79a38f8d`. This documentation update will +create the next exact head and therefore requires the protected checks to +rerun. + +- **Implemented in source:** PostgreSQL-backed API boundaries, Keyverse/OIDC + identity boundary, workspace navigation, post popup, ABAC/RBAC surfaces, Korean + summary, 5W1H, R&R/Keyman, customer hierarchy, tickets/calendar, chat, + provenance/evidence, and reconstructed lineage API/DAG layout. +- **Implemented in source, runtime evidence still required:** TEPP import/API + transport, contextual-orchestrator processing of the authorized corpus, + SearXNG corroboration, Local Zotero ingestion, real PostgreSQL import, and + complete accessibility/edge-case browser workflows. Synthetic routes and + health checks are recorded separately and are not corpus proof. +- **Implemented in source:** an explicit body-column or hash-verified + `multipart/related` MHTML artifact resolver now gives the private importer a + fail-closed path for exports whose PostgreSQL rows contain artifact + provenance but no body column. The operator artifact root and raw artifacts + remain outside the repository. +- **Figma reference:** ADR 0118 records file `1Su3lDRmiZdcUs47t1QwIX`; the + inspected Event Lineage frames are desktop `5:14` and mobile `5:15`. +- **Local quality evidence at the source/test head:** backend `uv run pytest -q` + passed `786` tests with `16` skips; frontend Vitest passed `172` tests in `19` + files, frontend lint/build passed, and Storybook build completed. These are + local checks, not hosted protected-gate or independent-review evidence. +- **Current PR gate:** PR #350 is open, `MERGEABLE` but `BLOCKED`, and has no + independent approval. Its current-head OpenCode/Noema/product/security + checks are pending after the latest push. The prior PR #347 merged at + `ef6f5a5ffcb467bd935dc1e53acc0029669b0bd7`, before this clean post-merge PR + was opened. PR #350 is still unmerged; this is not merge-ready evidence. + +## 2. UI/UX Standard Guide v3.0 comparison + +### 2.1 Satisfied or substantially present + +- Desktop shell has a sticky header, top-right user/logout controls, GNB, + footer with brand/copyright, standard breakpoints, 1920px maximum layout, + Noto Sans family, CI/BI palette tokens, table alignment tokens, focus styles, + required-field marker, and 50% modal backdrop. +- GNB active state is exposed with `aria-current`; the lineage DAG has keyboard + activation and branch/root/current visual states. +- PostgreSQL, orchestrator, TEPP, provenance, and synthetic-fixture boundaries + are documented in `ARCHITECTURE.md` and the applicable ADRs. + +### 2.2 Gaps and status + +- **Mobile drawer — fixed in this worktree:** CSS referenced a drawer trigger but + the authenticated shell rendered no trigger or drawer. The shell now renders + an accessible hamburger button, close action, overlay, and reusable WorkspaceNav. +- **Event Lineage Figma parity — fixed in this worktree:** the DAG now includes + lineage-evidence context, legend, horizontal overflow on phones, inference + boundary, direction markers, and an evidence trail table/cards treatment. +- **Approved CI/BI asset — open:** the header/footer currently render the + tenant brand name as text. Do not invent or alter a corporate logo; add the + approved asset only after the tenant CI/BI source and usage permission are + available. +- **Header utilities/search — partial:** the authenticated header now exposes a + global Search action that focuses the existing board search, and its pending + focus request is cleared when navigation leaves the board. A desktop site-map + utility now reuses `WorkspaceNav`, closes on Escape or destination selection, + and is omitted on phones where the drawer owns navigation; approved CI/BI + assets and a no-JavaScript fallback remain open. +- **Header top-menu language placement — fixed in this worktree:** UI/UX + Standard Guide v3.0 §2.2.2 assigns 언어설정 (language setting) to the header + top menu alongside user info, login/logout, search, and utility items. + `LanguageSwitcher` now renders inside `.app-header-top-menu` (`App.tsx`) + instead of the GNB row; the now-unused `WorkspaceNav` `tools` prop and + `.workspace-gnb-tools` CSS were removed. +- **Locale document metadata — substantially present:** `i18n.ts` synchronizes + `document.documentElement.lang` after locale selection and `i18n.test.ts` + covers the supported locales. `frontend/index.html` remains an English + pre-JavaScript fallback, so a no-JavaScript locale check is still open. +- **Event Lineage locale parity — fixed in this worktree:** the PR review + exposed ten Event Lineage, graph-evidence, navigation, and authorization + labels translated only for Korean while Chinese, Japanese, and Vietnamese + fell back to English. The three locale maps now contain those translations, + and the i18n test rejects raw-key fallback for every supported non-English + locale. This does not close the separate no-JavaScript fallback gap. +- **Phone content affordance — fixed and covered:** the authenticated 390px + browser sweep had a scrollable page and rendered the post list below the + sticky shell. A duplicate phone `.app-header` rule that overrode the required + vertical padding was removed; `mobileHeaderCss.test.ts` now enforces one + phone rule with `0.6rem 1rem` padding. The current exact-head frontend image + was rebuilt and the authenticated 390px sweep reconfirmed the header and + below-the-fold content affordance. +- **Large-body search migration — fixed in this worktree:** current PostgreSQL + migration replay initially exhausted the 58.8 GB container overlay while + building a raw HTML/Base64 body FTS index. The normalized search function and + intermediate FTS index now bound indexed rendered text to 16,384 characters; + the raw indexes are dropped by migration 0036. After reclaiming only Docker + build cache (never the PostgreSQL volume), the live migration completed with + exit 0 and the bounded function/index aggregate was verified. +- **Tenant settings replay — fixed in this worktree:** the idempotent migration + runner stopped at `0102`, so existing volumes returned a misleading CORS + symptom for `/api/settings` while the table was absent. The allowlist now + replays `0103_tenant_settings.sql`; the existing PostgreSQL volume applied it + with exit 0 and contains one tenant-settings row. Migration `0104` now + preserves the canonical `tenant_settings_id` column during replay. +- **Database identifier contract — fixed in this worktree:** the live public + schema audit found one single-token table and nine single-token persistent + columns, including the legacy bookmark, status, content, report, and tenant + settings fields. ADR 0120 and migration `0104_two_word_database_identifiers` + rename them to two-word snake_case names, retain stable API JSON names, and + recreate the current-status view. The replayed live schema now reports zero + single-token table/view/column violations and zero invalid indexes. 3NF, + hot-partition, lock, and read/write contention evidence remains open. +- **Metric superscript/subscript display — partially fixed in this worktree:** + bounded metric markup such as `m3` and `m3` is now + normalized consistently in the backend semantic parser and React renderer, + with focused tests. Arbitrary mathematical formula semantics remain open. + +### 2.3 Authenticated runtime evidence + +The local Compose stack accepted the synthetic Keycloak OIDC account and the +real React client rendered the protected board. Aggregate evidence only: + +- `/api/me` returned two authorized corporate entities and eight account + affiliations, with corporate and process-unit code/name fields. +- At a 390×958 viewport, logout, scope display, language control, 50 visible + posts, drawer open/close, and global-search-to-input focus all worked. +- On the rebuilt authenticated frontend, switching the header language to + Chinese set `html[lang]` to `zh`, localized the drawer, authorized-scope, + logout, summary, and Event Lineage labels, opened a post popup, and produced + zero popup errors. +- The authorized PostgreSQL export relation contained 43,814 rows with + complete title, source-key, and artifact-path metadata, but its schema had + no body/content/HTML column. The importer now accepts an explicit + body-column mapping or a path-column plus SHA-256-column mapping beneath an + operator-supplied artifact root; this is source and synthetic-fixture + evidence only because the authorized raw artifacts were not present in the + repository runtime. No real-corpus import or multimodal backfill is claimed + from this relation. +- After replaying the tenant-settings migration on the existing PostgreSQL + volume, authenticated `/api/settings` returned HTTP 200 and the fresh React + browser session recorded zero console errors and zero warnings. At 390×958, + the document had no horizontal overflow (`scrollWidth=390`) and the protected + board rendered the authenticated content surface. +- All 11 supplied post cases opened a popup with a loaded title and zero popup + error elements. The footnote case rendered one footnote, the table case one + semantic table, the known lineage case one DAG, and the image-table case + rendered five images but zero persisted image-region panels. +- The full lineage endpoint returned 500 bounded nodes and one edge; the focused + isolated case correctly returned an empty graph. PostgreSQL aggregates were + 43,839 source posts, 54 knowledge-graph edges, 65 edge-evidence rows, 1,308 + persisted post-lineage edges, and 1,929 posts participating in those edges. + This is evidence of a sparse current relationship projection, not evidence + that the Event Lineage product goal is complete. +- After the health-probe correction, the rebuilt backend returned unauthenticated + `/healthz` HTTP 200. The focused representative case in the later mixed + workspace image returned no DAG, so the earlier single-case DAG observation + must not be generalized to corpus coverage. + +## 3. Requirement traceability + +Status is intentionally evidence-specific: `source` means the implementation +boundary exists; `unit` means synthetic automated coverage exists; +`local-integration` means the local PostgreSQL/Keycloak/Valkey stack exercised +the path; `live-external` means the requested external service or authorized +corpus was exercised; `open` means the requirement is not yet proven. A source +adapter, fixture, or HTTP-shaped test double never upgrades a row to +`live-external`. + +| Requirement | Evidence at this audit | Status | +| --- | --- | --- | +| 1024/1280/1920 layout and three responsive tiers | ADR 0118, `App.css`, responsive popup width/secondary evidence grid, frontend build/tests | source + unit | +| Sticky header, footer, GNB, active state, phone drawer | `App.tsx`, `WorkspaceNav.tsx`, `App.test.tsx` | source + unit | +| Approved CI/BI logo asset | Tenant text is present; approved asset and permission are absent | open | +| User/logout/language/global search header actions | `App.tsx`, `i18n.ts`, handled/pending search-focus tests | source + unit | +| Site map / utility menu | `SiteMapUtility`, accessible toggle/region, Escape and destination-close behavior, responsive CSS contract, locale coverage | source + unit; authenticated browser evidence open | +| Noto Sans, palette, table/form/button conventions, modal 50% mask | ADR 0118, token CSS, component tests | source + unit | +| Keyverse/OIDC login with real account | `auth.py`, OIDC discovery/JWKS boundary, local redirect check | source + local-integration; Keyverse open | +| Authenticated corp/PU attributes | `/api/me` returns DB-backed codes; backend integration test covers `TEST-CORP`/`TEST-PU` and header displays them | source + local-integration | +| RBAC/ABAC, public/private visibility, tenant isolation | `_can_see_post`, API authorization tests, aggregate-only runtime checks | source + local-integration | +| React product surface and PostgreSQL boundary | React routes/components, asyncpg API, Compose stack | source + local-integration | +| Authorized PostgreSQL export import mapping | `scripts/import_postgresql_posts.py`, ADR 0121, hash-verified RFC 2557 MHTML resolver, and synthetic preflight/import tests; authorized relation has artifact-path metadata but no body/content/HTML field | source + unit + local-integration partial; operator artifact files and authorized live import open | +| Bounded large-body search migration | `0035_body_search_prefix.sql`, `0036_normalized_body_search.sql`; live replay completed after bounded rendered-text indexing | source + local-integration | +| Public Compose liveness and tenant settings boundary | health-probe regression test, `0103_tenant_settings.sql`, replayed existing volume, one tenant-settings row, canonical `tenant_settings_id` after `0104`, rebuilt backend `/healthz` and authenticated `/api/settings` HTTP 200 | source + unit + local-integration | +| Two-word snake_case database identifiers | ADR 0120, idempotent migration `0104`, live public-schema audit, zero invalid indexes | source + unit + local-integration | +| Post list/detail popup, Korean summary, 5W1H, R&R, tickets/calendar | API routes, popup panels, backend/frontend tests | source + unit | +| Keyman on both sides, titles, affiliations, related KG nodes | Keyman/affiliate-tree/related-node routes and popup | source + unit; live extraction open | +| Ontology, semantic layer, provenance, W3C PROV-O projection | normalized schema, SKOS operational vocabulary concepts, `ontology_annotations` label fallback, ADR 0124, provenance modules, ADRs, evidence UI | source + unit; corpus verification open | +| Branching Event Lineage DAG with evidence trail | `LineageDag.tsx`, Storybook story, Figma frames, accessible node-kind names for screen readers/tooltips, frontend tests; runtime cases include both a rendered DAG and honest empty states, while current corpus coverage remains sparse | source + unit + local-integration partial | +| Customer master and hierarchy tree | `/api/customer-master`, affiliate tree, catalog migrations | source + unit; live resolution open | +| VOC/VOM/VOP/VOCC/VOCO/VOS role classification | common lookup values and relationship APIs | source + unit; live classification open | +| Evidence-grounded chat and source navigation | `/chat`, `/ask`, citation/evidence UI | source + unit; synthetic orchestrator judge route verified, corpus chat/runtime evidence open | +| PU/team/project weekly/monthly reports | report API/UI and grouping controls | source + unit; TEPP-backed live report open | +| TEPP calibrated measurement, dichotomous items, multilevel/MMM/time model | published import/REST boundary and TEPP ADR/PRD references | boundary-only; live-external open | +| contextual-orchestrator routing, VISION, embedding, schema repair | clients and provenance/session boundary; synthetic authenticated route returned a judge score of `0.98`, OCR succeeded, and region location returned five regions | source + local-integration partial; corpus backfill, capability/readiness evidence, and schema-repair workflow open | +| HTML semantic units, tables, indentation, footnotes, formulas | parser modules and synthetic tests; 11-case authenticated popup sweep had no popup errors and rendered the supplied footnote/table cases; bounded metric superscript/subscript normalization has backend/frontend focused coverage | source + unit + local-integration partial; arbitrary formula/semantic correctness open | +| Base64/file image regions and multimodal evidence | image-region schema and VISION client boundary; live aggregate has 12,823 images, 22 described images/regions, and 422 failed images; current synthetic VISION route returned five regions | source + local-integration partial; supplied image-table case re-backfill and complete corpus coverage open | +| Abbreviation/multilingual alias/entity disambiguation | catalog hints and resolver boundary | source; live corroboration open | +| SearXNG/internal relation fact check | verification endpoint and unavailable handling; local SearXNG health and JSON query both returned HTTP 200, while some upstream engines reported rate-limit/CAPTCHA results | source + local-integration partial; corroboration policy and reliable external coverage open | +| Valkey event queue and cloud-native Compose stack | queue modules, Compose services, health checks | source + local-integration; delivery stress open | +| 3NF, hot partitions, locks, read/write contention | canonical identifier migration plus existing migrations | source; operational evidence open | +| Rust/GPU/CPU psychometric computation | delegated to TEPP, not reimplemented here | boundary accepted; live TEPP evidence open | +| APA 7 doctoring and Zotero OA records | baseline bibliography, local Zotero API reachable, known metadata found | source + local-integration; OA attachment audit open | +| Browser E2E from login through evidence | authenticated local OIDC login, protected list, drawer/search, popup sweep, and aggregate evidence checks at 390x958; post-migration fresh session had `htmlLang=zh`, localized protected shell, zero console errors/warnings, no horizontal overflow, popup, summary, and Event Lineage | source + local-integration; external provider/runtime evidence remains open | +| Storybook scenes/edge events and design-token coverage | `LineageDag.stories.tsx`, inventory, Storybook build | source + unit | +| External email/project lineage package boundary | PR #343 publishes strict v1.0.0 bounded request/result types, available-time cutoff handling, observed/inferred/proposed truth states, pair-budget enforcement, and no source/provider access | source + focused unit; exact-head hosted gates, independent review, and immutable release open | +| Naruon calendar projection boundary | PR #337 is closed as superseded; draft PR #355 carries the strict read projection contract without making LineageWeave a CalDAV provider | source + focused unit; Naruon endpoint, runtime wiring, restack, and review open | +| Hourly PR review/repair/merge loop | Central `ContextualWisdomLab/.github` scheduler owns `*/15 * * * *` sweep and `0 * * * *` heartbeat; no duplicate repo-local scheduler is required | boundary accepted; current-head runtime open | +| 100% coverage/docstrings/edge-case/release gates | current checks and coverage evidence are not complete on PR #350 | open | + +## 4. Supplied parsing and semantic cases + +The following user-reported cases remain tracked without storing real post IDs: + +- `case-footnote-01`: footnote/list `li`/`ol` boundary is misclassified. +- `case-table-01`: HTML table parsing fails. +- `case-indent-01` and `case-indent-02`: semantic indentation is wrong. +- `case-multi-project-01`: two projects must produce separate event streams; + internal facilities must not be guessed as Partner/Supplier. +- `case-image-table-01`: image tables need region-aware OCR/description and + rendered Markdown/table support. +- `case-summary-affiliation-01`: a role such as PM needs person, title, and + organization evidence rather than an unqualified collective label. +- `case-r-and-r-01`: requester, assignee, action, and cost/payment owner must + remain explicit in R&R evidence. +- `case-math-01`: bounded metric units such as m³ now have + superscript/subscript-preserving source/semantic normalization in backend and + frontend tests; arbitrary formula parsing, ontology-safe formula semantics, + and the authorized runtime case remain open. + +These are not “resolved” merely because a prompt or heuristic was changed. +Each requires synthetic unit coverage plus an authorized runtime reproduction +or an explicit unavailable result. + +## 5. Product and technical gaps + +- **Entity and abbreviation resolution — open:** canonical names, aliases, + multilingual labels, team-vs-organization typing, title-aware person + disambiguation, and SearXNG/internal corroboration need end-to-end evidence. +- **Image/HTML semantic units — partially implemented:** source DOM, layout + metadata, region evidence, and provenance must remain separate from embedding + text; transparent/unsupported image conversion and multimodal processing need + live verification. +- **Metric/formula semantics — partially fixed:** bounded metric markup is now + preserved across the backend parser and React renderer. Full formula AST, + units, exponents, and ontology mapping remain an evidence-backed follow-up, + not a claim of mathematical completeness. +- **Authorized source mapping — partially implemented:** the inspected export + relation exposes metadata and artifact paths but no body/content/HTML field. + ADR 0121 and the importer now connect a path plus SHA-256 mapping to an + operator-local MHTML root, while rejecting traversal, missing files, and + digest mismatches before writes. The remaining acceptance work is to mount + the authorized raw artifacts and run the real import/backfill; do not map an + unrelated metadata column as body. +- **TEPP measurement — boundary accepted, runtime open:** LineageWeave must + call TEPP through its published import/REST contract and must not implement a + local theta, psychometric calibration, CAT, or judge score. TEPP owns the + Rust numerical/psychometric layer and its multilevel/multiple-membership/time + model. +- **Orchestration — boundary accepted, runtime open:** all LLM/VISION/embedding + work must cross contextual-orchestrator with provenance, session, cost, schema + validation, synthesis/repair, and capability discovery. No provider key or + model selector belongs here. +- **Authorization — runtime open:** verify actual Keyverse/OIDC login, corp/PU + attributes, post visibility, ABAC/RBAC denials, and no cross-tenant evidence + leakage using aggregate, non-identifying results. +- **Database/operations — audit open:** verify 3NF constraints, hot-partition + behavior, lock boundaries, Valkey event delivery, multithreaded server + behavior, retention grants, and read/write contention on the local Compose + stack. +- **Lineage coverage — open:** the persisted graph has 1,308 post-lineage edges + across 1,929 participating posts, while the bounded current view exposed one + edge and some focused posts had no component. Add a rebuild/coverage gate that + distinguishes genuinely isolated posts from missing extraction or grouping + evidence before presenting a reader-facing branching DAG as complete. +- **Cross-repository email/project lineage — provider boundary implemented, + consumer open:** PR #343 provides the store-agnostic LineageWeave contract but + remains unmerged and unreleased. Naruon issue #1437 still needs a disabled-by- + default admission policy, durable idempotent analysis job, immutable artifact + pin, result projection, accept/correct/reject audit, and integration into the + existing email/thread/project surfaces. No draft branch, direct SQL, shared ORM, + credential forwarding, or automatic promotion of inferred facts is allowed. +- **Calendar interoperability — contract-only:** PR #337 defines the LineageWeave + consumer contract. Naruon must still publish the provider-side read endpoint, + service audience, provider conformance fixtures, sync/revision semantics, and + failure/reconciliation behavior before fail-closed runtime wiring is enabled. +- **Literature/Zotero — open:** record APA 7 references and verify Local Zotero + API availability before claiming synchronization. The repository must retain + only metadata/citations appropriate for public artifacts. +- **Release/quality gates — open:** current PR checks and required reviews must + complete on the exact current head; frontend, backend, browser, accessibility, + Storybook, security, and coverage evidence must be collected before release. + +## 6. Next acceptance loop + +1. Re-fetch the exact PR head and required reviews/checks for the workspace UI stack, + PR #343, and the superseding calendar contract PR #355. +2. Run frontend lint, tests, build, Storybook, backend tests, and authenticated + browser checks when the local stack is available. +3. Reproduce each case label using synthetic fixtures or authorized runtime + aggregates, preserving `unavailable` as an explicit result. +4. Complete exact-head review and immutable release of the LineageWeave provider + contract before creating the Naruon consumer implementation; do not import a + draft branch or share application database state. +5. Fix only evidence-backed failures, then repeat the exact-head protected merge + gate. Do not self-approve, bypass protection, or claim a PR is merged without + a merge SHA. + +## 7. References (APA 7th) + +ContextualWisdomLab. (2026). *TEPP* [Computer software]. GitHub. +https://github.com/ContextualWisdomLab/TEPP + +ContextualWisdomLab. (2026). *contextual-orchestrator architecture notes* +[Computer software]. GitHub. +https://github.com/ContextualWisdomLab/contextual-orchestrator/blob/main/docs/architecture.md + +Nielsen, S., Cetin, E., Schwendeman, P., Sun, Q., Xu, J., & Tang, Y. (2025). +*Learning to orchestrate agents in natural language with the Conductor*. +arXiv. https://doi.org/10.48550/arXiv.2512.04388 + +Tang, Y., Cetin, E., Xu, J., Sun, Q., Nielsen, S., Richard, V., Goda, H., +Tymchenko, I., Nguyen, N., Lee, H., Ashiga, M., Kotyan, S., & Kuroki, S. +(2026). *Sakana Fugu technical report*. arXiv. +https://arxiv.org/abs/2606.21228 + +Xu, J., Sun, Q., Schwendeman, P., Nielsen, S., Cetin, E., & Tang, Y. (2025). +*TRINITY: An evolved LLM coordinator*. arXiv. +https://doi.org/10.48550/arXiv.2512.04695 + +World Wide Web Consortium. (2024). *Web Content Accessibility Guidelines + (WCAG) 2.2* (W3C Recommendation). https://www.w3.org/TR/WCAG22/ diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index 28c59bd48..334001f5d 100644 --- a/docs/storybook-inventory.md +++ b/docs/storybook-inventory.md @@ -1,14 +1,15 @@ # Storybook inventory Open the catalog after `cd frontend && pnpm run storybook`. Each story is a -buyer-facing control you can click before changing product CSS. +reader-facing control you can click before changing product CSS. -| Story | Buyer next action | Token / module | +| Story | Reader next action | Token / module | |---|---|---| | `Evidence/CitationChip` | Click a cited title to open that source post. | `--color-chip-border`, `--radius-chip`, `CitationChip` | | `AnalysisRun/CutoffKnownBody` | Read the cutoff-known sentence, then compare it with the live body below. | `--color-accent-border`, `--space-panel-block`, `--radius-panel`, `CutoffKnownBody` | | `Analysis/LineageEntityPicker` | Choose which corp to reconstruct, then click Request a lineage reconstruction. | `--space-control-gap`, `--size-control-min`, `--radius-control`, `LineageEntityPicker` | | `Chrome/PopupCloseButton` | Close the evidence panel or post popup. | `--space-close-inset`, `--font-size-close`, `PopupCloseButton` | +| `Evidence/LineageDag` | Inspect a branching Event Lineage, then open a record or read its evidence trail. | `--color-primary`, `--color-accent-orange`, `LineageDag` | Repeated web objects must use `frontend/src/styles/tokens.css` and a module under `frontend/src/components/`. Do not add a second Node package manager; diff --git a/frontend/src/App.css b/frontend/src/App.css index 0b1eec4f8..d188b03e9 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -8,7 +8,10 @@ .app-shell > main { flex: 1; - padding: 1.5rem; + width: 100%; + box-sizing: border-box; + padding: clamp(1.5rem, 3vw, 2.75rem) clamp(1rem, 4vw, 3rem); + background: var(--bg); } /* Login Screen (§3.2 로그인 페이지) */ @@ -64,6 +67,26 @@ } /* App Header (§2.2.1 & §2.2.2) */ +/* Skip link: hidden off-canvas until keyboard-focused, so Tab from the + top of the page reaches main content without stepping through the + header top-menu and the five-item GNB first. */ +.skip-link { + position: absolute; + top: 0.75rem; + left: 1rem; + z-index: var(--z-skip-link); + padding: 0.6rem 1rem; + background: var(--color-primary); + color: var(--color-btn-primary-text); + border-radius: var(--radius-control); + transform: translateY(-4rem); + transition: transform 0.15s ease-in-out; +} + +.skip-link:focus { + transform: translateY(0); +} + .app-header { position: sticky; top: 0; @@ -97,6 +120,46 @@ align-items: center; } +.site-map-utility { + position: relative; +} + +.site-map-menu { + position: absolute; + top: calc(100% + 0.5rem); + right: 0; + z-index: var(--z-gnb-pulldown); + min-width: 14rem; + padding: 0.5rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-control); + box-shadow: var(--shadow); +} + +.site-map-menu .workspace-gnb { + flex-direction: column; + align-items: stretch; + height: auto; + gap: 0.25rem; + margin: 0; + padding: 0; + border: 0; +} + +.site-map-menu .workspace-gnb-item { + width: 100%; + min-height: var(--size-control-min); + height: auto; + justify-content: flex-start; + padding: 0.65rem 0.75rem; + border-radius: var(--radius-control); +} + +.site-map-menu .workspace-gnb-item[aria-current="page"]::after { + display: none; +} + .app-user-profile { font-size: 0.85rem; font-weight: 600; @@ -107,9 +170,22 @@ border: 1px solid var(--border); } +.app-account-scope { + max-width: 22rem; + overflow: hidden; + color: var(--text-muted); + font-size: 0.75rem; + text-overflow: ellipsis; + white-space: nowrap; +} + /* Drawer Menu Trigger (Mobile) */ .mobile-drawer-trigger { display: none; + min-width: var(--size-control-min); + min-height: var(--size-control-min); + align-items: center; + justify-content: center; background: transparent; border: none; font-size: 1.5rem; @@ -117,6 +193,38 @@ color: var(--color-text-heading); } +.mobile-drawer-backdrop { + position: fixed; + inset: 0; + z-index: var(--z-drawer-backdrop); + background: rgba(0, 0, 0, 0.5); +} + +.mobile-drawer { + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: min(20rem, 88vw); + padding: 1rem; + background: var(--surface); + box-shadow: var(--shadow); + overflow-y: auto; + z-index: var(--z-drawer); +} + +.mobile-drawer-close { + display: block; + margin-left: auto; + min-width: var(--size-control-min); + min-height: var(--size-control-min); + border: 0; + background: transparent; + color: var(--text-h); + font-size: 1.5rem; + cursor: pointer; +} + /* App Footer (§2.2.3 & §2.2.4) */ .app-footer { margin-top: auto; @@ -142,7 +250,7 @@ } /* GNB Navigation (§2.3.1) */ -.buyer-gnb { +.workspace-gnb { display: flex; align-items: center; height: var(--gnb-height); @@ -155,7 +263,7 @@ z-index: var(--z-gnb-pulldown); } -.buyer-gnb-item { +.workspace-gnb-item { display: flex; align-items: center; height: 100%; @@ -170,15 +278,15 @@ transition: color 0.15s ease; } -.buyer-gnb-item:hover { +.workspace-gnb-item:hover { color: var(--color-text-heading); } -.buyer-gnb-item[aria-current="page"] { +.workspace-gnb-item[aria-current="page"] { color: var(--color-primary); } -.buyer-gnb-item[aria-current="page"]::after { +.workspace-gnb-item[aria-current="page"]::after { content: ""; position: absolute; bottom: 0; @@ -188,12 +296,6 @@ background-color: var(--gnb-active-indicator-color); } -.buyer-gnb-tools { - margin-left: auto; - display: flex; - align-items: center; -} - /* Button Standards (§4.3) */ .btn-primary { background: var(--color-btn-primary-bg); @@ -201,6 +303,7 @@ border: 1px solid transparent; border-radius: var(--radius-control); padding: 0.5rem 1.15rem; + min-height: var(--size-control-min); font-weight: 600; cursor: pointer; transition: background-color 0.15s ease-in-out; @@ -210,79 +313,711 @@ background: var(--color-btn-primary-hover); } -.btn-secondary { - background: var(--color-btn-secondary-bg); - color: var(--color-btn-secondary-text); - border: 1px solid var(--color-btn-secondary-border); - border-radius: var(--radius-control); - padding: 0.45rem 1rem; - font-weight: 600; - cursor: pointer; - transition: background-color 0.15s ease-in-out; +.btn-secondary { + background: var(--color-btn-secondary-bg); + color: var(--color-btn-secondary-text); + border: 1px solid var(--color-btn-secondary-border); + border-radius: var(--radius-control); + padding: 0.45rem 1rem; + min-height: var(--size-control-min); + font-weight: 600; + cursor: pointer; + transition: background-color 0.15s ease-in-out; +} + +.btn-secondary:hover { + background: var(--color-btn-secondary-hover); +} + +/* Language Switcher */ +.language-switcher { + display: inline-flex; + align-items: center; +} + +.language-switcher select { + min-height: var(--size-control-min); + padding: 0.35rem 1.8rem 0.35rem 0.65rem; + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--surface); + color: var(--text-h); + font: inherit; + font-size: max(16px, 0.82rem); + cursor: pointer; +} + +.error { + color: var(--color-status-alert); +} + +.status-alert { + color: var(--color-status-alert); +} + +/* Board: the primary find-and-open workflow */ +.board-surface, +.workspace-destination { + width: min(100%, var(--layout-content-width)); + margin: 0 auto; +} + +.board-header { + display: flex; + justify-content: space-between; + align-items: flex-end; + gap: 1rem; + margin-bottom: 1.5rem; +} + +.board-header h2, +.workspace-destination h2 { + margin: 0.15rem 0 0.35rem; + font-size: clamp(1.5rem, 2.2vw, 2rem); + letter-spacing: -0.04em; +} + +.board-header > div > p:last-child, +.workspace-destination-intro { + color: var(--text-muted); +} + +.ask-agent-workspace { + max-width: 920px; + min-height: calc(100vh - 13rem); + display: flex; + flex-direction: column; +} + +.ask-agent-header { + margin-bottom: 1rem; +} + +.ask-agent-thread { + flex: 1; + width: 100%; + padding: 0.5rem 0 1.25rem; +} + +.ask-agent-empty { + min-height: 34vh; + display: grid; + place-content: center; + justify-items: center; + padding: 2rem; + color: var(--text-muted); + text-align: center; +} + +.ask-agent-mark { + display: grid; + place-items: center; + width: 3rem; + height: 3rem; + border-radius: 1rem; + background: var(--color-primary); + color: #fff; + font-size: 0.8rem; + font-weight: 800; + letter-spacing: 0.04em; + box-shadow: 0 8px 20px rgba(42, 98, 239, 0.22); +} + +.ask-agent-empty h3 { + margin: 1rem 0 0.35rem; + color: var(--text-h); + font-size: 1.1rem; +} + +.ask-agent-empty p { + max-width: 28rem; +} + +.ask-agent-turn { + display: grid; + gap: 0.75rem; + max-width: 52rem; + margin: 0 auto 1.5rem; +} + +.ask-agent-message-row { + display: grid; + grid-template-columns: 2rem minmax(0, 1fr); + gap: 0.75rem; + align-items: start; +} + +.ask-agent-avatar { + display: grid; + place-items: center; + width: 2rem; + height: 2rem; + border-radius: 0.7rem; + font-size: 0.68rem; + font-weight: 800; +} + +.ask-agent-user-avatar { + border: 1px solid var(--border); + background: var(--color-table-row-hover); + color: var(--text-h); +} + +.ask-agent-assistant-avatar { + background: var(--color-primary); + color: var(--color-btn-primary-text); +} + +.ask-agent-message { + min-width: 0; + padding: 0.8rem 1rem; + border: 1px solid var(--border); + border-radius: var(--radius-panel); + line-height: 1.65; +} + +.ask-agent-user-message { + background: var(--surface-muted); +} + +.ask-agent-assistant-message { + background: var(--surface); +} + +.ask-agent-message-label { + margin: 0 0 0.3rem; + color: var(--text-muted); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.ask-agent-message > p { + margin: 0 0 0.65rem; +} + +.ask-agent-message > p:last-child { + margin-bottom: 0; +} + +.ask-agent-pending { + color: var(--text-muted); +} + +.ask-agent-error { + color: var(--color-danger, #b42318); +} + +.ask-agent-citations { + margin-top: 1rem; + padding-top: 0.85rem; + border-top: 1px solid var(--border); +} + +.ask-agent-citations h4 { + margin: 0 0 0.5rem; + color: var(--text-muted); + font-size: 0.78rem; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.ask-agent-citation-list { + display: grid; + gap: 0.5rem; + list-style: none; + padding: 0; + margin: 0; +} + +.ask-agent-citation { + display: flex; + width: 100%; + min-height: 2.75rem; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.55rem 0.7rem; + border: 1px solid var(--color-accent-border); + border-radius: var(--radius-control); + background: var(--color-accent-background); + color: var(--text-h); + cursor: pointer; + font: inherit; + text-align: left; +} + +.ask-agent-citation span { + color: var(--color-primary); + font-size: 0.75rem; + font-weight: 700; + white-space: nowrap; +} + +.ask-agent-composer { + position: sticky; + bottom: 0.75rem; + z-index: 2; + width: 100%; + max-width: 52rem; + margin: auto; + padding: 0.65rem; + border: 1px solid var(--border); + border-radius: var(--radius-panel); + background: var(--surface); + box-shadow: 0 10px 28px rgba(16, 24, 40, 0.12); +} + +.ask-agent-composer:focus-within { + border-color: var(--color-focus-border); + box-shadow: 0 0 0 3px var(--color-focus-ring), 0 10px 28px rgba(16, 24, 40, 0.12); +} + +.ask-agent-composer-field { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; + gap: 0.5rem; +} + +.ask-agent-composer textarea { + width: 100%; + min-height: 3rem; + max-height: 10rem; + resize: vertical; + padding: 0.7rem 0.8rem; + border: 0; + outline: none; + background: transparent; + color: var(--text-h); + font: inherit; + line-height: 1.45; +} + +.ask-agent-send { + display: grid; + place-items: center; + width: 44px; + height: 44px; + border: 0; + border-radius: var(--radius-control); + background: var(--color-primary); + color: var(--color-btn-primary-text); + cursor: pointer; +} + +.ask-agent-send:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.ask-agent-composer-help { + margin: 0.35rem 0.35rem 0; + color: var(--text-muted); + font-size: 0.72rem; +} + +.customer-master-list { + display: grid; + gap: 0.55rem; + list-style: none; + padding: 0; + margin: 1.25rem 0 0; +} + +.customer-master-tree-children { + margin-top: 0.55rem; + padding-left: 1rem; + border-left: 2px solid var(--color-accent-border); +} + +.customer-entity-button { + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + padding: 0.75rem 0.9rem; + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--surface); + color: var(--text-h); + cursor: pointer; + text-align: left; +} + +.customer-entity-button:hover, +.customer-entity-button[aria-expanded="true"] { + border-color: var(--color-accent-border); + background: var(--color-accent-background); +} + +.customer-entity-button span { + color: var(--text-muted); + font-size: 0.8rem; +} + +.customer-related-posts { + margin: 0.45rem 0 0.75rem; + padding: 0.75rem; + border-left: 2px solid var(--border); +} + +.customer-keymen { + margin-top: 2rem; + padding-top: 1.5rem; + border-top: 1px solid var(--border); +} + +.customer-keymen h3 { + margin: 0 0 0.5rem; + font-size: 1.1rem; +} + +.customer-keymen > .customer-master-list > li { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem 0.75rem; + padding: 0.8rem 0.9rem; + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--surface); +} + +.customer-keymen details, +.customer-keymen details > ul { + flex-basis: 100%; +} + +.customer-keymen details > ul { + margin: 0.65rem 0 0; +} + +.related-post-card { + width: 100%; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 0.9rem; + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--surface); + color: var(--text-h); + cursor: pointer; + text-align: left; +} + +.related-post-card:hover { + border-color: var(--color-accent-border); + background: var(--color-accent-background); +} + +.related-post-content { + min-width: 0; + display: grid; + gap: 0.25rem; +} + +.related-post-content .post-body-excerpt { + color: var(--text-muted); + font-size: 0.82rem; +} + +.post-evidence-list { + display: grid; + gap: 0.35rem; + list-style: none; + margin: 0.5rem 0 0; + padding: 0.65rem 0.8rem; + border-left: 2px solid var(--color-accent-border); + background: var(--bg); +} + +.post-evidence-list li { + display: grid; + gap: 0.15rem; +} + +.post-evidence-list li span:first-child { + color: var(--text-muted); + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; +} + +.board-result-count { + flex: 0 0 auto; + padding: 0.5rem 0.75rem; + border: 1px solid var(--color-accent-border); + border-radius: var(--radius-chip); + background: var(--color-accent-background); + color: var(--color-primary); + font-size: 0.8rem; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.board-controls { + margin-bottom: 1.5rem; + padding: 1rem; + border: 1px solid var(--border); + border-radius: var(--radius-panel); + background: var(--surface); + box-shadow: 0 6px 18px rgba(19, 37, 63, 0.05); +} + +.board-search-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.75rem; + align-items: end; +} + +.board-controls label, +.board-voc-type-filter legend { + color: var(--text-muted); + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.board-controls label { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.board-controls input[type="search"], +.board-controls select { + width: 100%; + min-height: var(--size-control-min); + padding: 0.65rem 0.8rem; + border: 1px solid var(--color-btn-secondary-border); + border-radius: var(--radius-control); + background: var(--surface); + color: var(--text-h); +} + +.board-controls input[type="search"]::placeholder { + color: var(--text-muted); + opacity: 0.8; +} + +.board-search-help { + margin: 0.45rem 0 1rem; + font-size: 0.8rem; +} + +.board-filter-row { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(10rem, 0.28fr) minmax(10rem, 0.28fr) auto; + gap: 0.75rem; + align-items: end; +} + +.board-voc-type-filter { + min-width: 0; + grid-column: 1 / -1; + display: flex; + flex-wrap: wrap; + gap: 0.45rem 0.85rem; + margin: 0; + padding: 0.7rem 0.8rem 0.8rem; + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--bg); +} + +.board-voc-type-filter legend { + padding: 0 0.25rem; +} + +.board-voc-type-filter label { + display: inline-flex; + flex-direction: row; + align-items: center; + gap: 0.35rem; + color: var(--text); + font-size: 0.82rem; + font-weight: 500; + letter-spacing: 0; + text-transform: none; +} + +.board-voc-type-filter input { + width: 1rem; + height: 1rem; + accent-color: var(--color-primary); +} + +.board-reset { + white-space: nowrap; +} + +/* Post List */ +.post-list { + list-style: none; + padding: 0; + margin: 0; + display: grid; + gap: 0.75rem; +} + +.post-card { + border: 1px solid var(--border); + border-radius: var(--radius-panel); + background: var(--surface); + overflow: hidden; + transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease; +} + +.post-card:hover { + border-color: var(--color-accent-border); + box-shadow: 0 8px 20px rgba(19, 37, 63, 0.08); + transform: translateY(-1px); +} + +.post-list-item { + width: 100%; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + gap: 1rem; + padding: 1rem 1.1rem; + border: 0; + background: transparent; + color: var(--text); + cursor: pointer; + text-align: left; + font: inherit; +} + +.post-card-main { + min-width: 0; + display: grid; + gap: 0.3rem; +} + +.post-title, +.post-body-excerpt, +.post-card-main > .post-meta { + display: block; +} + +.post-title { + color: var(--text-h); + font-size: 1rem; + font-weight: 700; + line-height: 1.45; +} + +.post-body-excerpt { + display: -webkit-box; + overflow: hidden; + color: var(--text); + font-size: 0.9rem; + line-height: 1.55; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + +.post-card-main > .post-meta { + font-size: 0.77rem; + line-height: 1.4; +} + +.post-card-badges { + max-width: 12rem; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.35rem; +} + +.post-badge { + display: inline-flex; + align-items: center; + min-height: 1.5rem; + padding: 0.2rem 0.5rem; + border: 1px solid var(--border); + border-radius: var(--radius-chip); + background: var(--bg); + color: var(--text-muted); + font-size: 0.68rem; + font-weight: 700; + line-height: 1.2; + text-align: right; + text-transform: uppercase; +} + +.post-card .post-list-item:focus-visible { + outline: 2px solid var(--color-focus-border); + outline-offset: -3px; +} + +.post-list-item[aria-current="true"], +.ticket-list-item[aria-current="true"] { + border-color: var(--color-accent-info); + box-shadow: inset 0 0 0 1px var(--color-accent-info); } -.btn-secondary:hover { - background: var(--color-btn-secondary-hover); +.board-empty { + padding: 2rem 1rem; + border: 1px dashed var(--border); + border-radius: var(--radius-panel); + background: var(--surface); + color: var(--text-muted); + text-align: center; } -/* Language Switcher */ -.language-switcher { - display: inline-flex; +.board-pagination { + display: flex; + justify-content: center; align-items: center; + flex-wrap: wrap; + gap: 0.35rem; + margin-top: 1.5rem; } -.language-switcher select { +.board-pagination button { + min-width: var(--size-control-min); min-height: var(--size-control-min); - padding: 0.35rem 1.8rem 0.35rem 0.65rem; + padding: 0.45rem 0.65rem; border: 1px solid var(--border); border-radius: var(--radius-control); background: var(--surface); color: var(--text-h); - font: inherit; - font-size: 0.82rem; cursor: pointer; } -.error { - color: var(--color-status-alert); -} - -.status-alert { - color: var(--color-status-alert); +.board-pagination button[aria-current="page"] { + border-color: var(--color-primary); + background: var(--color-primary); + color: var(--color-btn-primary-text); + font-weight: 700; } -/* Post List */ -.post-list { - list-style: none; - padding: 0; - margin: 0; +.board-pagination button:disabled { + cursor: not-allowed; + opacity: 0.45; } -.post-list-item { - width: 100%; - display: flex; - justify-content: space-between; - align-items: center; - padding: 0.75rem 1rem; - margin-bottom: 0.5rem; +.advanced-review-tools { + margin-top: 2rem; + padding: 1rem; border: 1px solid var(--border); - border-radius: var(--radius-control); - background: none; - cursor: pointer; - text-align: left; - font-size: 1rem; -} - -.post-list-item[aria-current="true"], -.ticket-list-item[aria-current="true"] { - border-color: var(--color-accent-info); - box-shadow: inset 0 0 0 1px var(--color-accent-info); + border-radius: var(--radius-panel); + background: var(--surface); } -.post-badge { - font-size: 0.75rem; - opacity: 0.7; - text-transform: uppercase; +.advanced-review-tools summary { + color: var(--text-h); + cursor: pointer; + font-weight: 700; } /* Popup / Modals (§3.6.1 모달 레이어 투명도 50%) */ @@ -300,20 +1035,75 @@ position: relative; background: var(--surface); color: var(--text); - max-width: 720px; - width: 90%; + width: min(90%, 820px); max-height: 85vh; + /* Wide desktop viewports get more than the 820px mobile-safe cap so + multi-column sections (popup-analysis-grid, popup-secondary-grid) + have room to sit side by side instead of forcing a narrow single + column on a 1920px-wide shell. */ + @media (min-width: 1280px) { + width: min(85%, 1180px); + } overflow-y: auto; - padding: 2rem; - border-radius: 12px; + padding: clamp(1.25rem, 3vw, 2rem); + border: 1px solid var(--border); + border-radius: var(--radius-panel); z-index: var(--z-modal); box-shadow: var(--shadow); } +.popup-panel > h2 { + max-width: calc(100% - 3rem); + margin: 0 0 0.35rem; + font-size: clamp(1.2rem, 2.3vw, 1.6rem); + line-height: 1.35; +} + +.post-actions { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; + margin-top: 0.8rem; +} + +.post-actions button { + min-height: var(--size-control-min); + padding: 0.45rem 0.85rem; + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--surface); + color: var(--text-h); + cursor: pointer; + font-weight: 600; +} + +.post-actions button:hover, +.post-actions button[aria-pressed="true"] { + border-color: var(--color-primary); + background: var(--color-accent-background); + color: var(--color-primary); +} + +.post-actions button:disabled { + cursor: wait; + opacity: 0.55; +} + +.post-action-status { + margin-top: 0.6rem; + color: var(--color-primary); + font-size: 0.85rem; +} + .popup-close { position: absolute; top: var(--space-close-inset); right: var(--space-close-inset); + min-width: var(--size-control-min); + min-height: var(--size-control-min); + display: flex; + align-items: center; + justify-content: center; background: none; border: none; font-size: var(--font-size-close); @@ -405,6 +1195,44 @@ border-top: 1px solid var(--border); } +/* Korean summary next to 5W1H so both are scannable at a glance instead + of stacked below the raw post body (UI/UX Standard Guide Ver.3.0 SS3.1 + item order: summary/key events/R&R read first). */ +.popup-analysis-grid { + display: grid; + grid-template-columns: 1fr; + gap: 0 1.5rem; +} + +@media (min-width: 768px) { + .popup-analysis-grid { + grid-template-columns: 1fr 1fr; + align-items: start; + } +} + +/* Projects/semantic evidence and Original source state are both short, + standalone reference blocks (no "next action" reading order between + them) — let them sit side by side on wide viewports instead of each + claiming the full popup width. Falls back to one column when only one + of the two is rendered. */ +.popup-secondary-grid { + display: grid; + grid-template-columns: 1fr; + gap: 0 1.5rem; +} + +@media (min-width: 768px) { + .popup-secondary-grid { + grid-template-columns: 1fr 1fr; + align-items: start; + } + + .popup-secondary-grid > .popup-section:only-child { + grid-column: 1 / -1; + } +} + .popup-section h3 { margin: 0 0 0.5rem; font-size: 1rem; @@ -487,6 +1315,65 @@ margin: 0 0 1.25rem; } +.lineage-dag-header { + margin-bottom: 1rem; +} + +.lineage-dag-header h4 { + margin: 0.15rem 0 0.35rem; +} + +.lineage-dag-description { + margin: 0; + color: var(--text-muted); + font-size: 0.9rem; +} + +.lineage-dag-legend { + display: flex; + flex-wrap: wrap; + gap: 0.75rem 1rem; + margin-bottom: 1rem; + color: var(--text-muted); + font-size: 0.8rem; +} + +.lineage-dag-legend-item { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.lineage-dag-legend-mark { + display: inline-block; + width: 0.7rem; + height: 0.7rem; + border: 2px solid var(--border); + border-radius: 50%; + background: var(--surface-muted); +} + +.lineage-dag-legend-root { + border-color: var(--color-accent-info); +} + +.lineage-dag-legend-branch { + border-color: var(--color-accent-orange); + background: var(--badge-actor-organization-bg); +} + +.lineage-dag-legend-current { + border-color: var(--text-h); + border-width: 3px; +} + +.lineage-dag-viewport { + overflow-x: auto; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); +} + .lineage-dag-group figcaption { font-size: 0.85rem; opacity: 0.8; @@ -494,9 +1381,8 @@ } .lineage-dag svg { - border: 1px solid var(--border); - border-radius: 8px; - background: var(--surface); + display: block; + min-width: 100%; } .lineage-dag-edge { @@ -543,6 +1429,38 @@ stroke: var(--text-h); } +.lineage-dag-inference-note { + margin: 1rem 0; + padding: 0.75rem 1rem; + border-left: 3px solid var(--color-primary); + background: var(--color-accent-background); + color: var(--text); + font-size: 0.85rem; +} + +.lineage-dag-inference-note strong { + display: block; + margin-bottom: 0.2rem; + color: var(--text-h); +} + +.lineage-dag-evidence { + margin-top: 1rem; +} + +.lineage-dag-evidence h4 { + margin-bottom: 0.5rem; +} + +.lineage-dag-evidence table { + width: 100%; +} + +.lineage-dag-evidence td:last-child { + text-align: right; + font-variant-numeric: tabular-nums; +} + .keyman-list { list-style: none; padding: 0; @@ -820,7 +1738,11 @@ .app-header { padding: 0 1rem; } - .buyer-gnb { + + .app-account-scope { + display: none; + } + .workspace-gnb { padding: 0 1rem; } } @@ -828,16 +1750,184 @@ @media (max-width: 768px) { /* Phone Breakpoint (<768px) */ - .buyer-gnb { + .app-shell > main { + padding: 1.25rem 0.9rem 2rem; + } + + .board-header { + align-items: flex-start; + flex-direction: column; + margin-bottom: 1.15rem; + } + + .board-result-count { + align-self: stretch; + text-align: center; + } + + .board-controls { + padding: 0.75rem; + } + + .board-search-row, + .board-filter-row { + grid-template-columns: 1fr; + } + + .board-search-row .btn-primary { + width: 100%; + } + + .board-voc-type-filter { + grid-column: auto; + } + + .post-list-item { + grid-template-columns: 1fr; + gap: 0.8rem; + padding: 0.9rem; + } + + .post-card-badges { + max-width: none; + flex-direction: row; + flex-wrap: wrap; + align-items: flex-start; + } + + .post-badge { + text-align: left; + } + + .post-card:hover { + transform: none; + } + + .ask-agent-workspace { + min-height: calc(100vh - 11rem); + } + + .ask-agent-thread { + padding-bottom: 0.75rem; + } + + .ask-agent-message-row { + grid-template-columns: 1.75rem minmax(0, 1fr); + gap: 0.55rem; + } + + .ask-agent-avatar { + width: 1.75rem; + height: 1.75rem; + } + + .ask-agent-message { + padding: 0.7rem 0.8rem; + } + + .ask-agent-citation { + align-items: flex-start; + flex-direction: column; + gap: 0.2rem; + } + + .ask-agent-composer { + bottom: 0.5rem; + } + + /* Fixed height + no-wrap pushed the search/language/logout controls + off the right edge of narrow viewports (unreachable, not just + visually cramped -- logout must always stay reachable per SS3.2). */ + .app-header { + height: auto; + min-height: var(--header-height); + flex-wrap: wrap; + row-gap: 0.5rem; + padding: 0.6rem 1rem; + } + + .app-header-top-menu { + flex-wrap: wrap; + justify-content: flex-end; + row-gap: 0.5rem; + } + + .site-map-utility { + display: none; + } + + .workspace-gnb { display: none; /* Replaced by drawer on mobile */ } .mobile-drawer-trigger { + display: flex; + } + + .mobile-drawer .workspace-gnb { + display: flex; + flex-direction: column; + align-items: stretch; + height: auto; + margin: 0.5rem 0 0; + padding: 0; + gap: 0.25rem; + border-bottom: 0; + } + + .mobile-drawer .workspace-gnb-item { + justify-content: flex-start; + width: 100%; + min-height: 3rem; + height: auto; + padding: 0.75rem; + border-radius: var(--radius-control); + } + + .mobile-drawer .workspace-gnb-item[aria-current="page"]::after { + display: none; + } + + .mobile-drawer .workspace-gnb-item[aria-current="page"] { + background: var(--color-accent-background); + } + + .lineage-dag-viewport { + padding-bottom: 0.25rem; + } + + .lineage-dag svg { + min-width: 42rem; + } + + .lineage-dag-evidence td { display: block; + text-align: left !important; } - .app-header { - padding: 0 1rem; + .lineage-dag-evidence tr { + display: block; + margin-bottom: 0.5rem; + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--surface); + } + + .lineage-dag-evidence th { + display: none; + } + + .lineage-dag-evidence td { + border: 0; + } + + .lineage-dag-evidence td::before { + content: attr(data-label); + display: block; + margin-bottom: 0.15rem; + color: var(--text-muted); + font-size: 0.75rem; + font-weight: 700; } .app-footer { @@ -846,3 +1936,14 @@ gap: 0.5rem; } } + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 7462abd2c..69d7ca008 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -24,6 +24,10 @@ beforeEach(() => { signinRedirect, signoutRedirect, }; + // Post navigation now pushes real history entries (browser back should + // close the popup); reset between tests so one test's opened post doesn't + // leak into the next test's initial render via a stale `?post=` query. + window.history.replaceState({}, "", "/"); }); afterEach(() => { @@ -84,13 +88,14 @@ describe("App, authenticated", () => { pendingTeppRun?: boolean; pluralAffiliations?: boolean; deferMe?: boolean; + deferPosts?: boolean; meFailed?: boolean; postBody?: string; manyCustomerHints?: number; customerEntityHierarchy?: boolean; staleSummary?: boolean; contentAfterSummary?: boolean; - }): ReturnType & { releaseMe: () => void } { + }): ReturnType & { releaseMe: () => void; releasePosts: () => void } { const statusLabel: Record = { open: "Open", in_progress: "In progress", @@ -117,11 +122,17 @@ describe("App, authenticated", () => { let contentRequests = 0; let releaseMe = () => {}; + let releasePosts = () => {}; const meReady = options?.deferMe ? new Promise((resolve) => { releaseMe = resolve; }) : Promise.resolve(); + const postsReady = options?.deferPosts + ? new Promise((resolve) => { + releasePosts = resolve; + }) + : Promise.resolve(); const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); @@ -152,6 +163,16 @@ describe("App, authenticated", () => { { corporate_entity_id: "corp-north", entity_name: "Northridge Grid" }, ] : [{ corporate_entity_id: "corp-demo", entity_name: "Demo Corp" }], + account_affiliations: [ + { + corporate_entity_id: "corp-demo", + corporate_entity_code: "DEMO-CORP", + entity_name: "Demo Corp", + process_unit_id: "pu-demo", + process_unit_code: "DEMO-PU", + process_unit_name: "Demo PU", + }, + ], }); }); } @@ -1035,7 +1056,7 @@ describe("App, authenticated", () => { } const postsUrl = new URL(url, "https://backend.test"); if (postsUrl.pathname === "/api/posts") { - return Promise.resolve( + return postsReady.then(() => jsonResponse( postsUrl.searchParams.get("search") ? [] @@ -1657,7 +1678,7 @@ describe("App, authenticated", () => { return Promise.reject(new Error(`unexpected fetch: ${method} ${url}`)); }); vi.stubGlobal("fetch", fetchMock); - return Object.assign(fetchMock, { releaseMe }); + return Object.assign(fetchMock, { releaseMe, releasePosts }); } it("renders safe Ask Agent evidence under each cited post", async () => { @@ -1668,12 +1689,28 @@ describe("App, authenticated", () => { await userEvent.type(screen.getByRole("textbox", { name: "Ask a question" }), "Which project?"); await userEvent.click(screen.getByRole("button", { name: "Ask" })); + expect(screen.getByRole("log", { name: "Conversation" })).toBeInTheDocument(); + expect(screen.getByText("Which project?", { exact: true })).toBeInTheDocument(); expect(await screen.findByRole("list", { name: "Evidence facts" })).toBeInTheDocument(); expect(screen.getByText("Semantic project", { exact: true })).toBeInTheDocument(); expect(screen.getByText(/project: Semantic project \| evidence: Body evidence/)).toBeInTheDocument(); expect(screen.queryByText(/ontology_iri|contextual_orchestrator/i)).not.toBeInTheDocument(); }); + it("renders the conversation empty state and submits an Ask Agent question with Enter", async () => { + stubBackend(); + render(); + await userEvent.click(await screen.findByRole("button", { name: "Ask Agent" })); + + expect(screen.getByRole("log", { name: "Conversation" })).toHaveAttribute("aria-busy", "false"); + expect(screen.getByText("Start with a question about the evidence")).toBeInTheDocument(); + const input = screen.getByRole("textbox", { name: "Ask a question" }); + await userEvent.type(input, "Which project?{Enter}"); + + expect(await screen.findByText("Which project?", { selector: ".ask-agent-user-message p:last-child" })).toBeInTheDocument(); + expect(input).toHaveValue(""); + }); + it("labels the Customer Master entity level and Keymen side, never the raw lookup code", async () => { // Live UI finding (2026-08-19): read_customer_master() skipped the // common_lookup_value join both endpoints elsewhere already use, @@ -1806,12 +1843,12 @@ describe("App, authenticated", () => { await userEvent.click(screen.getByRole("button", { name: "View post: Public post" })); expect(await screen.findByLabelText("A-100 lineage")).toBeInTheDocument(); - expect(screen.getByLabelText("Open post: Pricing renegotiation follow-up")).toHaveClass( - "lineage-dag-branch", - ); - expect(screen.getByLabelText("Open post: Unrelated: annual account review")).toHaveClass( - "lineage-dag-root", - ); + expect( + screen.getByLabelText("Open post: Pricing renegotiation follow-up (Branch point)"), + ).toHaveClass("lineage-dag-branch"); + expect( + screen.getByLabelText("Open post: Unrelated: annual account review (Root record)"), + ).toHaveClass("lineage-dag-root"); }); it("renders the board landmark and functional post controls", async () => { @@ -1979,7 +2016,9 @@ describe("App, authenticated", () => { expect(relatedPosts).toHaveTextContent("Linked post"); // The Event Lineage DAG belongs to the opened post, not the list surface. expect(screen.getAllByLabelText("A-100 lineage")).toHaveLength(1); - expect(screen.getAllByLabelText("Open post: Pricing renegotiation follow-up")).toHaveLength(1); + expect( + screen.getAllByLabelText("Open post: Pricing renegotiation follow-up (Branch point)"), + ).toHaveLength(1); expect(document.getElementById("post-event-lineage")).not.toHaveFocus(); expect(document.getElementById("post-ask")).not.toHaveFocus(); expect( @@ -2002,6 +2041,9 @@ describe("App, authenticated", () => { const eventLineage = within(popup as HTMLElement).getByRole("heading", { name: "Event Lineage" }); const affiliate = within(popup as HTMLElement).getByRole("heading", { name: "Affiliate tree" }); const keyman = within(popup as HTMLElement).getByRole("heading", { name: "Keymen" }); + expect(within(popup as HTMLElement).getByText("Lineage evidence")).toBeInTheDocument(); + expect(within(popup as HTMLElement).getByText("Inference boundary")).toBeInTheDocument(); + expect(within(popup as HTMLElement).getByRole("table", { name: /Evidence trail/ })).toBeInTheDocument(); expect(evaluation.compareDocumentPosition(eventLineage) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe( 0, ); @@ -2100,7 +2142,7 @@ describe("App, authenticated", () => { ); }); - it("stops loading and gives the buyer a next action when cited evidence is unavailable", async () => { + it("stops loading and gives the reader a next action when cited evidence is unavailable", async () => { stubBackend({ evidenceUnavailable: true }); render(); @@ -2549,7 +2591,7 @@ describe("App, authenticated", () => { expect(screen.getByText("due 2026-01-09")).toBeInTheDocument(); }); - it("tells the buyer how to populate an empty calendar", async () => { + it("tells the reader how to populate an empty calendar", async () => { stubBackend({ calendarCommitments: [] }); render(); @@ -2953,7 +2995,9 @@ describe("App, authenticated", () => { ).toHaveLength(1); const popup = document.querySelector(".popup-panel"); expect(popup).not.toBeNull(); - const currentNode = within(popup as HTMLElement).getByLabelText("Open post: Public post"); + const currentNode = within(popup as HTMLElement).getByLabelText( + "Open post: Public post (Current record, Root record)", + ); expect(currentNode).toHaveAttribute("aria-current", "true"); const lineageNext = screen.getByRole("status", { name: "Event Lineage next action" }); expect(lineageNext).toHaveTextContent( @@ -3501,12 +3545,155 @@ describe("App, authenticated", () => { ); }); - it("keeps advanced review tools out of the buyer board", async () => { + it("keeps advanced review tools out of the workspace board", async () => { stubBackend(); render(); - expect(await screen.findByRole("navigation", { name: "Buyer navigation" })).toBeInTheDocument(); + expect(await screen.findByRole("navigation", { name: "Workspace navigation" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Board" })).toHaveAttribute("aria-current", "page"); + expect(screen.getByLabelText("Authorized scope")).toHaveTextContent("DEMO-CORP / DEMO-PU"); expect(screen.queryByText("Advanced review tools")).not.toBeInTheDocument(); + const mobileMenu = screen.getByRole("button", { name: "Open navigation" }); + expect(mobileMenu).toHaveAttribute("aria-expanded", "false"); + await userEvent.click(mobileMenu); + expect(screen.getByRole("button", { name: "Close Workspace navigation" })).toHaveAttribute( + "aria-expanded", + "true", + ); + expect(screen.getAllByRole("button", { name: "Close" })).toHaveLength(1); + expect(document.getElementById("mobile-workspace-navigation")).toBeInTheDocument(); + const drawerClose = document.querySelector(".mobile-drawer-close"); + expect(drawerClose).not.toBeNull(); + await userEvent.click(drawerClose as HTMLButtonElement); + expect(screen.getByRole("button", { name: "Open navigation" })).toHaveAttribute("aria-expanded", "false"); + const appHeader = document.querySelector("header.app-header"); + expect(appHeader).not.toBeNull(); + expect(within(appHeader as HTMLElement).getByLabelText("Language")).toBeInTheDocument(); + expect(screen.getByRole("navigation", { name: "Workspace navigation" })).not.toHaveTextContent("Language"); + await userEvent.click(screen.getByRole("button", { name: "Open navigation" })); + const mobileNavigation = document.getElementById("mobile-workspace-navigation"); + expect(mobileNavigation).not.toBeNull(); + await userEvent.click(within(mobileNavigation as HTMLElement).getByRole("button", { name: "Customer master" })); + expect(await screen.findByRole("heading", { name: "Customer master" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Open navigation" })).toHaveAttribute( + "aria-expanded", + "false", + ); + await userEvent.click(screen.getByRole("button", { name: "Open navigation" })); + expect(screen.getByRole("button", { name: "Close Workspace navigation" })).toBeInTheDocument(); + await userEvent.click( + within(appHeader as HTMLElement).getByRole("button", { name: "Search" }), + ); + expect(document.getElementById("mobile-workspace-navigation")).not.toBeInTheDocument(); + await waitFor(() => + expect(screen.getByRole("searchbox", { name: "Search semantic evidence" })).toHaveFocus(), + ); + }); + + it("opens the site map utility and closes it after navigation or Escape", async () => { + stubBackend(); + render(); + + const siteMapButton = await screen.findByRole("button", { name: "Site map" }); + expect(siteMapButton).toHaveAttribute("aria-expanded", "false"); + await userEvent.click(siteMapButton); + expect(screen.getByRole("region", { name: "Site map" })).toBeInTheDocument(); + expect(siteMapButton).toHaveAttribute("aria-expanded", "true"); + + await userEvent.keyboard("{Escape}"); + expect(screen.queryByRole("region", { name: "Site map" })).not.toBeInTheDocument(); + + await userEvent.click(siteMapButton); + const siteMap = screen.getByRole("region", { name: "Site map" }); + await userEvent.click(within(siteMap).getByRole("button", { name: "Customer master" })); + expect(await screen.findByRole("heading", { name: "Customer master" })).toBeInTheDocument(); + expect(screen.queryByRole("region", { name: "Site map" })).not.toBeInTheDocument(); + }); + + it("lets a keyboard user skip the header and GNB to reach main content", async () => { + stubBackend(); + render(); + + await screen.findByRole("navigation", { name: "Workspace navigation" }); + const skipLink = screen.getByRole("link", { name: "Skip to main content" }); + expect(skipLink).toHaveAttribute("href", "#main-content"); + const main = document.getElementById("main-content"); + expect(main).not.toBeNull(); + await userEvent.click(skipLink); + expect(main).toHaveFocus(); + }); + + it("does not steal focus after the global search request has been handled", async () => { + const fetchMock = stubBackend(); + render(); + + await userEvent.click(await screen.findByRole("button", { name: "Customer master" })); + expect(await screen.findByRole("heading", { name: "Customer master" })).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Search" })); + + const searchInput = await screen.findByRole("searchbox", { name: "Search semantic evidence" }); + await waitFor(() => expect(searchInput).toHaveFocus()); + + const sort = screen.getByLabelText("Sort posts"); + await userEvent.selectOptions(sort, "title"); + await waitFor(() => { + expect(fetchMock.mock.calls.some(([url]) => String(url).includes("sort=title"))).toBe(true); + expect(sort).toHaveFocus(); + }); + + await userEvent.click(screen.getByRole("button", { name: "Customer master" })); + const boardButton = screen.getByRole("button", { name: "Board" }); + await userEvent.click(boardButton); + const remountedSearchInput = await screen.findByRole("searchbox", { + name: "Search semantic evidence", + }); + await waitFor(() => expect(boardButton).toHaveFocus()); + expect(remountedSearchInput).not.toHaveFocus(); + }); + + it("focuses the board search input again on a later global Search action", async () => { + stubBackend(); + render(); + + const header = document.querySelector("header.app-header"); + expect(header).not.toBeNull(); + const globalSearch = within(header as HTMLElement).getByRole("button", { name: "Search" }); + const searchInput = await screen.findByRole("searchbox", { name: "Search semantic evidence" }); + + await userEvent.click(globalSearch); + await waitFor(() => expect(searchInput).toHaveFocus()); + searchInput.blur(); + expect(searchInput).not.toHaveFocus(); + + await userEvent.click(globalSearch); + await waitFor(() => expect(searchInput).toHaveFocus()); + }); + + it("clears a pending global search focus request when leaving the board", async () => { + const fetchMock = stubBackend({ deferPosts: true }); + render(); + + await userEvent.click(screen.getByRole("button", { name: "Search" })); + await userEvent.click(screen.getByRole("button", { name: "Customer master" })); + expect(await screen.findByRole("heading", { name: "Customer master" })).toBeInTheDocument(); + + fetchMock.releasePosts(); + await userEvent.click(screen.getByRole("button", { name: "Board" })); + const searchInput = await screen.findByRole("searchbox", { name: "Search semantic evidence" }); + expect(searchInput).not.toHaveFocus(); + }); + + it("closes a post popup when browser history moves back", async () => { + stubBackend(); + render(); + + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + expect(await screen.findByRole("heading", { name: "Public post" })).toBeInTheDocument(); + expect(new URL(window.location.href).searchParams.get("post")).toBe("post-1"); + + window.history.replaceState({}, "", "/"); + window.dispatchEvent(new PopStateEvent("popstate")); + + await waitFor(() => expect(screen.queryByRole("heading", { name: "Public post" })).not.toBeInTheDocument()); }); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 666888a4d..40a8be40c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -49,6 +49,7 @@ import { verifyPostRelations, type ActivityEvent, type AskAgentResponse, + type CurrentUser, type AffiliateNode, type AnalysisRun, type CalendarResponse, @@ -86,7 +87,8 @@ import { CitationChip } from "./components/CitationChip"; import { CutoffKnownBody } from "./components/CutoffKnownBody"; import { LineageEntityPicker } from "./components/LineageEntityPicker"; import { PopupCloseButton } from "./components/PopupCloseButton"; -import { BuyerNav, type BuyerDestination } from "./components/BuyerNav"; +import { WorkspaceNav, type WorkspaceDestination } from "./components/WorkspaceNav"; +import { MenuIcon, CloseIcon, SendIcon } from "./components/icons"; import { LineageDag } from "./LineageDag"; import { PostBody } from "./PostBody"; import { decodeHtmlEntities } from "./postBodyDisplay"; @@ -135,6 +137,38 @@ function LanguageSwitcher({ accessToken }: { accessToken?: string }) { ); } +function SiteMapUtility({ + destination, + onChange, + open, + onToggle, +}: { + destination: WorkspaceDestination; + onChange: (destination: WorkspaceDestination) => void; + open: boolean; + onToggle: () => void; +}) { + return ( +
+ + {open ? ( +
+ +
+ ) : null} +
+ ); +} + function searchUnavailableMessage(err: unknown): string { if (err instanceof BackendError && err.status === 503) { return t("Verification unavailable (search is not configured)."); @@ -351,8 +385,10 @@ function ChatPanel({ onChange={(event) => setQuestion(event.target.value)} onKeyDown={(event) => event.key === "Enter" && handleAsk()} placeholder={t("What happened between these events?")} + aria-label={t("What happened between these events?")} /> @@ -1590,6 +1626,7 @@ function IssueTicketPanel({ onChange={(event) => setNewTitle(event.target.value)} onKeyDown={(event) => event.key === "Enter" && handleCreate()} placeholder={t("New ticket title")} + aria-label={t("New ticket title")} /> ) : null} -
-

{t("Post body")}

- {post.post_body.trim() ? ( - - ) : ( -

- {t("Source body was not imported; summary and semantic extraction are unavailable.")} -

- )} -
- {(post.source_stage_code || - post.source_detail_state_code || - post.source_draft_code || - post.source_deleted_flag || - post.source_author_code || - post.source_author_name || - post.source_company_code || - post.source_company_name || - post.source_process_unit_code || - post.source_process_unit_name || - post.source_sales_pool_code || - post.source_sales_pool_name || - post.source_customer_code || - post.source_customer_name || - post.source_project_code || - post.source_project_name || - post.source_system_code || - post.source_record_key) && ( -
-

{t("Original source state")}

-
- {post.source_stage_code ? ( - <> -
{t("Source stage")}
-
{post.source_stage_code}
- - ) : null} - {post.source_detail_state_code ? ( - <> -
{t("Source detail state")}
-
{post.source_detail_state_code}
- - ) : null} - {post.source_draft_code ? ( - <> -
{t("Source draft marker")}
-
{post.source_draft_code}
- - ) : null} - {post.source_deleted_flag ? ( - <> -
{t("Source deletion marker")}
-
{post.source_deleted_flag}
- - ) : null} - {post.source_author_code ? ( - <> -
{t("Source author code")}
-
{post.source_author_code}
- - ) : null} - {post.source_author_name ? ( - <> -
{t("Source author name")}
-
{post.source_author_name}
- - ) : null} - {post.source_company_code ? ( - <> -
{t("Source company code")}
-
{post.source_company_code}
- - ) : null} - {post.source_company_name ? ( - <> -
{t("Source company name")}
-
{post.source_company_name}
- - ) : null} - {post.source_process_unit_name ? ( - <> -
{t("Source process unit name")}
-
{post.source_process_unit_name}
- - ) : null} - {post.source_process_unit_code ? ( - <> -
{t("Source business unit")}
-
{post.source_process_unit_code}
- - ) : null} - {post.source_sales_pool_code ? ( - <> -
{t("Source sales pool")}
-
{post.source_sales_pool_code}
- - ) : null} - {post.source_sales_pool_name ? ( - <> -
{t("Source sales pool name")}
-
{post.source_sales_pool_name}
- - ) : null} - {post.source_customer_code ? ( - <> -
{t("Source customer code")}
-
{post.source_customer_code}
- - ) : null} - {post.source_customer_name ? ( - <> -
{t("Source customer name")}
-
{post.source_customer_name}
- - ) : null} - {post.source_project_code ? ( - <> -
{t("Source project code")}
-
{post.source_project_code}
- - ) : null} - {post.source_project_name ? ( - <> -
{t("Source project name")}
-
{post.source_project_name}
- - ) : null} - {post.source_system_code ? ( - <> -
{t("Source system")}
-
{post.source_system_code}
- - ) : null} - {post.source_record_key ? ( - <> -
{t("Source record key")}
-
{post.source_record_key}
- - ) : null} -
-

{t("Raw source codes are shown; no state label was inferred.")}

-
- )} - - - - {post.project_evidence && post.project_evidence.length > 0 ? ( -
-

{t("Projects / semantic evidence")}

-
    - {post.project_evidence.map((project) => ( -
  • - {" "} - {project.confidence === null - ? `(${t("Hint only")})` - : `(${Math.round(project.confidence * 100)}%)`} - : {project.evidence} -
    - {t("Evidence provenance")} - - {t("Ontology class")}: {t(project.ontology_label ?? "Project")} - - - {t("Extraction source")}: {projectExtractionLabel(project.extraction_method)} - - - {t("Evidence field")}: {projectProvenanceLabel(project.provenance)} - -
    -
  • - ))} -
-
- ) : null} -
+
+

{t("Summary")}

{summary ? ( <> @@ -2266,6 +2121,195 @@ function PostDetailPopup({ ) : (

{t("No summary is available for this record yet.")}

)} +
+
+ +
+
+ +
+ {post.project_evidence && post.project_evidence.length > 0 ? ( +
+

{t("Projects / semantic evidence")}

+
    + {post.project_evidence.map((project) => ( +
  • + {" "} + {project.confidence === null + ? `(${t("Hint only")})` + : `(${Math.round(project.confidence * 100)}%)`} + : {project.evidence} +
    + {t("Evidence provenance")} + + {t("Ontology class")}: {t(project.ontology_label ?? "Project")} + + + {t("Extraction source")}: {projectExtractionLabel(project.extraction_method)} + + + {t("Evidence field")}: {projectProvenanceLabel(project.provenance)} + +
    +
  • + ))} +
+
+ ) : null} + + {(post.source_stage_code || + post.source_detail_state_code || + post.source_draft_code || + post.source_deleted_flag || + post.source_author_code || + post.source_author_name || + post.source_company_code || + post.source_company_name || + post.source_process_unit_code || + post.source_process_unit_name || + post.source_sales_pool_code || + post.source_sales_pool_name || + post.source_customer_code || + post.source_customer_name || + post.source_project_code || + post.source_project_name || + post.source_system_code || + post.source_record_key) && ( +
+

{t("Original source state")}

+
+ {post.source_stage_code ? ( + <> +
{t("Source stage")}
+
{post.source_stage_code}
+ + ) : null} + {post.source_detail_state_code ? ( + <> +
{t("Source detail state")}
+
{post.source_detail_state_code}
+ + ) : null} + {post.source_draft_code ? ( + <> +
{t("Source draft marker")}
+
{post.source_draft_code}
+ + ) : null} + {post.source_deleted_flag ? ( + <> +
{t("Source deletion marker")}
+
{post.source_deleted_flag}
+ + ) : null} + {post.source_author_code ? ( + <> +
{t("Source author code")}
+
{post.source_author_code}
+ + ) : null} + {post.source_author_name ? ( + <> +
{t("Source author name")}
+
{post.source_author_name}
+ + ) : null} + {post.source_company_code ? ( + <> +
{t("Source company code")}
+
{post.source_company_code}
+ + ) : null} + {post.source_company_name ? ( + <> +
{t("Source company name")}
+
{post.source_company_name}
+ + ) : null} + {post.source_process_unit_name ? ( + <> +
{t("Source process unit name")}
+
{post.source_process_unit_name}
+ + ) : null} + {post.source_process_unit_code ? ( + <> +
{t("Source business unit")}
+
{post.source_process_unit_code}
+ + ) : null} + {post.source_sales_pool_code ? ( + <> +
{t("Source sales pool")}
+
{post.source_sales_pool_code}
+ + ) : null} + {post.source_sales_pool_name ? ( + <> +
{t("Source sales pool name")}
+
{post.source_sales_pool_name}
+ + ) : null} + {post.source_customer_code ? ( + <> +
{t("Source customer code")}
+
{post.source_customer_code}
+ + ) : null} + {post.source_customer_name ? ( + <> +
{t("Source customer name")}
+
{post.source_customer_name}
+ + ) : null} + {post.source_project_code ? ( + <> +
{t("Source project code")}
+
{post.source_project_code}
+ + ) : null} + {post.source_project_name ? ( + <> +
{t("Source project name")}
+
{post.source_project_name}
+ + ) : null} + {post.source_system_code ? ( + <> +
{t("Source system")}
+
{post.source_system_code}
+ + ) : null} + {post.source_record_key ? ( + <> +
{t("Source record key")}
+
{post.source_record_key}
+ + ) : null} +
+

{t("Raw source codes are shown; no state label was inferred.")}

+
+ )} +
+ +
+

{t("Post body")}

+ {post.post_body.trim() ? ( + + ) : ( +

+ {t("Source body was not imported; summary and semantic extraction are unavailable.")} +

+ )}
{!focusEventLineage && ( @@ -2537,6 +2581,10 @@ type SelectPostOptions = { liveAfterCutoff?: boolean; knowledgeCutoff?: string; fromReportMember?: boolean; + /** Set when re-entering a post from a popstate (browser back/forward) so + * the handler doesn't push a duplicate history entry for a navigation + * the browser already performed. */ + fromPopState?: boolean; }; /** @@ -3573,11 +3621,15 @@ function PostList({ showLabPanels = false, postIdToOpen = null, onPostOpened, + focusSearchRequest = 0, + onSearchFocusHandled, }: { accessToken: string; showLabPanels?: boolean; postIdToOpen?: string | null; onPostOpened?: () => void; + focusSearchRequest?: number; + onSearchFocusHandled?: () => void; }) { const [posts, setPosts] = useState(null); const [graph, setGraph] = useState(null); @@ -3608,6 +3660,24 @@ function PostList({ const [visibilityFilterOptions, setVisibilityFilterOptions] = useState([]); const [sortOrder, setSortOrder] = useState("newest"); const postsRequest = useRef(0); + const searchInputRef = useRef(null); + const lastFocusedSearchRequest = useRef(0); + + useEffect(() => { + if (focusSearchRequest <= 0) { + // The parent intentionally reuses 1 after each handled request resets + // its counter to 0. Reset the local guard with it so the next global + // Search action can focus the input again. + lastFocusedSearchRequest.current = 0; + return; + } + if (focusSearchRequest <= lastFocusedSearchRequest.current) return; + const input = searchInputRef.current; + if (!input) return; + lastFocusedSearchRequest.current = focusSearchRequest; + input.focus(); + onSearchFocusHandled?.(); + }, [focusSearchRequest, onSearchFocusHandled, posts]); function openReportFromAnalysisRun( periodCode: string, @@ -3647,6 +3717,13 @@ function PostList({ setOpenedAfterCutoff(Boolean(options?.liveAfterCutoff)); setOpenedCutoffIso(options?.knowledgeCutoff ?? null); setOpenedFromReportMember(Boolean(options?.fromReportMember)); + if (!options?.fromPopState) { + const url = new URL(window.location.href); + if (url.searchParams.get("post") !== postId) { + url.searchParams.set("post", postId); + window.history.pushState({}, "", `${url.pathname}${url.search}${url.hash}`); + } + } } useEffect(() => { @@ -3655,6 +3732,22 @@ function PostList({ onPostOpened?.(); }, [onPostOpened, postIdToOpen]); + useEffect(() => { + function handlePopState() { + const postId = new URLSearchParams(window.location.search).get("post"); + if (postId) { + selectPost(postId, { fromPopState: true }); + } else { + setSelectedPostId(null); + setOpenedAfterCutoff(false); + setOpenedCutoffIso(null); + setOpenedFromReportMember(false); + } + } + window.addEventListener("popstate", handlePopState); + return () => window.removeEventListener("popstate", handlePopState); + }, []); + function closeSelectedPost() { setSelectedPostId(null); setOpenedAfterCutoff(false); @@ -3838,69 +3931,74 @@ function PostList({ setSortOrder("newest"); }} > - - +
+ + +

{t("Search includes post text and semantic evidence.")}

-
- {t("Filter by VOC type")} - {vocTypeOptions.map((option) => ( - - ))} -
- ))} - - - - {hasBoardFilters && ( - - )} + + + + {hasBoardFilters && ( + + )} + {posts.length === 0 ? (

@@ -4292,10 +4390,10 @@ function CustomerMasterPanel({ } return ( -

+

{t("Authorized customer scope")}

{t("Customer master")}

-

{t("Customer entities available to this account.")}

+

{t("Customer entities available to this account.")}

{error ?

{error}

: null} {master === null && !error ?

{t("Loading customer master...")}

: null} {master?.corporate_entities.length === 0 ? ( @@ -4320,7 +4418,7 @@ function CustomerMasterPanel({ {master && (master.relationship_network ?? []).length > 0 ? (

{t("Relationship network")}

-

+

{t("A counterparty can hold more than one role over time -- a customer in one post can be a competitor, supplier, or partner in another. Every role observed for a name is listed, not just the most frequent.")}

    @@ -4343,7 +4441,7 @@ function CustomerMasterPanel({ {master && master.source_customer_hints.length > 0 ? (

    {t("Observed customer evidence")}

    -

    +

    {t("Source identifiers are hints only; ontology and semantic evidence must resolve them before binding a customer.")}

    {master.source_customer_hints.length > HINT_RENDER_LIMIT && ( @@ -4465,6 +4563,14 @@ function CustomerMasterPanel({ ); } +type AskAgentExchange = { + id: number; + question: string; + status: "pending" | "complete" | "error"; + response?: AskAgentResponse; + error?: string; +}; + function AskAgentPanel({ accessToken, onOpenPost, @@ -4473,76 +4579,130 @@ function AskAgentPanel({ onOpenPost: (postId: string) => void; }) { const [question, setQuestion] = useState(""); - const [answer, setAnswer] = useState(null); - const [error, setError] = useState(null); + const [exchanges, setExchanges] = useState([]); const [asking, setAsking] = useState(false); + const exchangeIdRef = useRef(0); async function handleAsk() { const normalized = question.trim(); - if (!normalized) return; + if (!normalized || asking) return; + const exchangeId = ++exchangeIdRef.current; + setExchanges((current) => [...current, { id: exchangeId, question: normalized, status: "pending" }]); + setQuestion(""); setAsking(true); - setError(null); try { - setAnswer(await askAgent(accessToken, normalized)); + const response = await askAgent(accessToken, normalized); + setExchanges((current) => current.map((exchange) => ( + exchange.id === exchangeId ? { ...exchange, status: "complete", response } : exchange + ))); } catch (err) { - setAnswer(null); - setError(orchestratorUnavailableMessage(err, t("Ask Agent"))); + setExchanges((current) => current.map((exchange) => ( + exchange.id === exchangeId + ? { ...exchange, status: "error", error: orchestratorUnavailableMessage(err, t("Ask Agent")) } + : exchange + ))); } finally { setAsking(false); } } return ( -
    -

    {t("Evidence-grounded questions")}

    -

    {t("Ask Agent")}

    -

    {t("Questions use authorized posts and their evidence.")}

    - {error ?

    {error}

    : null} -