diff --git a/.github/workflows/repair-global-ask-pnpm-v2.yml b/.github/workflows/repair-global-ask-pnpm-v2.yml new file mode 100644 index 000000000..a2ef5489f --- /dev/null +++ b/.github/workflows/repair-global-ask-pnpm-v2.yml @@ -0,0 +1,80 @@ +name: Repair Global Ask pnpm provisioning deterministically + +on: + workflow_dispatch: + push: + branches: + - "feat/global-ask-public-claim-verification-v2200" + paths: + - ".github/workflows/repair-global-ask-pnpm-v2.yml" + +permissions: + contents: write + +concurrency: + group: repair-global-ask-pnpm-v2200-v2 + cancel-in-progress: false + +jobs: + repair: + name: Pin repository pnpm and re-arm product integration + runs-on: ubuntu-latest + steps: + - name: Checkout exact feature branch + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 + with: + ref: feat/global-ask-public-claim-verification-v2200 + fetch-depth: 0 + persist-credentials: true + + - name: Repair only the package-manager provisioning boundary + shell: bash + run: | + python - <<'PY' + from pathlib import Path + + workflow = Path('.github/workflows/apply-global-ask-public-verification-v2200.yml') + text = workflow.read_text(encoding='utf-8') + + actor_guard = " github.event.pull_request.head.repo.full_name == github.repository &&\n github.actor != 'github-actions[bot]'" + if actor_guard in text: + text = text.replace( + actor_guard, + " github.event.pull_request.head.repo.full_name == github.repository", + 1, + ) + + old_install = " corepack enable\n pnpm --dir frontend install --frozen-lockfile" + new_install = ( + " corepack enable\n" + " corepack prepare pnpm@9.15.9 --activate\n" + " test \"$(pnpm --version)\" = \"9.15.9\"\n" + " pnpm --dir frontend install --frozen-lockfile" + ) + if old_install in text: + text = text.replace(old_install, new_install, 1) + elif new_install not in text: + raise SystemExit('refusing to edit an unknown pnpm provisioning shape') + + if "github.actor != 'github-actions[bot]'" in text: + raise SystemExit('actor guard remains after repair') + if new_install not in text: + raise SystemExit('pinned pnpm provisioning was not installed') + + workflow.write_text(text, encoding='utf-8') + PY + + - name: Remove repair-only workflows and publish the narrow repair + shell: bash + run: | + rm -f .github/workflows/repair-global-ask-pnpm.yml + rm .github/workflows/repair-global-ask-pnpm-v2.yml + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A .github/workflows + git diff --cached --check + if git diff --cached --quiet; then + exit 0 + fi + git commit -m "ci: pin Global Ask pnpm provisioning" + git push origin HEAD:feat/global-ask-public-claim-verification-v2200 diff --git a/AGENTS.md b/AGENTS.md index 25c482840..49ce7c418 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -139,6 +139,10 @@ contextual-orchestrator owns model discovery and selection. signals with contextual-orchestrator adjudication when evidence conflicts; heuristics are not authoritative and must not be the only fallback for an unresolved structure decision. +- Source-system codes may be enriched with catalog display names under ADR + 0117. Pass those names to contextual-orchestrator as labeled lookup hints + only; never promote them to an entity binding, customer fact, project fact, + or imported-author affiliation without post evidence. - 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 diff --git a/CHANGELOG.d/2.12.6-buyer-image-source-safety.md b/CHANGELOG.d/2.12.6-buyer-image-source-safety.md new file mode 100644 index 000000000..f4aeb5293 --- /dev/null +++ b/CHANGELOG.d/2.12.6-buyer-image-source-safety.md @@ -0,0 +1,6 @@ +# 2.12.6 — Validate buyer image sources + +## Fixed + +- Buyer image rendering now rejects script, SVG, external, and malformed + source URLs before they reach an image element. diff --git a/CHANGELOG.d/2.12.6-frontend-build-gate.md b/CHANGELOG.d/2.12.6-frontend-build-gate.md new file mode 100644 index 000000000..75d7d6590 --- /dev/null +++ b/CHANGELOG.d/2.12.6-frontend-build-gate.md @@ -0,0 +1,3 @@ +## Fixed + +- Keep the unauthenticated login surface free of authenticated admin controls and remove unused OIDC imports so TypeScript production builds pass. diff --git a/CHANGELOG.d/2.12.6-oidc-deep-link-safety.md b/CHANGELOG.d/2.12.6-oidc-deep-link-safety.md new file mode 100644 index 000000000..48599f982 --- /dev/null +++ b/CHANGELOG.d/2.12.6-oidc-deep-link-safety.md @@ -0,0 +1,7 @@ +# 2.12.6 — Bound OIDC deep-link state parsing + +## Fixed + +- OIDC callback state is parsed at most once and length-bounded before JSON + handling, preventing recursive encoded state from exhausting the browser + stack while preserving safe same-origin post deep links. diff --git a/CHANGELOG.d/2.12.6-provider-error-boundary.md b/CHANGELOG.d/2.12.6-provider-error-boundary.md new file mode 100644 index 000000000..06a0699b2 --- /dev/null +++ b/CHANGELOG.d/2.12.6-provider-error-boundary.md @@ -0,0 +1,3 @@ +## Fixed + +- Keep contextual-orchestrator, OIDC, RankWeave, TEPP, and durable-ingestion diagnostics behind stable product error boundaries while retaining the original exception for server-side chaining. diff --git a/CHANGELOG.d/2.13.1-mixed-body-indentation.md b/CHANGELOG.d/2.13.1-mixed-body-indentation.md new file mode 100644 index 000000000..a287b3058 --- /dev/null +++ b/CHANGELOG.d/2.13.1-mixed-body-indentation.md @@ -0,0 +1,7 @@ +# Mixed table and paragraph indentation + +## Fixed + +- Match persisted post units to their source text instead of using ordinal + position, so a table or embedded image cannot shift the fallback indentation + of a later unresolved paragraph. diff --git a/CHANGELOG.d/2.13.1-partial-image-regions.md b/CHANGELOG.d/2.13.1-partial-image-regions.md new file mode 100644 index 000000000..b3da62568 --- /dev/null +++ b/CHANGELOG.d/2.13.1-partial-image-regions.md @@ -0,0 +1,5 @@ +## Preserve partial visual regions + +- Retain valid salient image regions for panel-level OCR and search. +- Also analyze the parent image when locator coverage is partial so text outside + the returned panels remains searchable. diff --git a/CHANGELOG.d/2.13.1-separate-source-tables.md b/CHANGELOG.d/2.13.1-separate-source-tables.md new file mode 100644 index 000000000..0896de45c --- /dev/null +++ b/CHANGELOG.d/2.13.1-separate-source-tables.md @@ -0,0 +1,7 @@ +# Preserve adjacent source tables + +## Fixed + +- Keep consecutive persisted rows in separate buyer-facing tables when the + source post contains more than one HTML table, preserving the authored table + boundary without changing source text or semantic row content. diff --git a/CHANGELOG.d/2.13.1-source-indent-semantics.md b/CHANGELOG.d/2.13.1-source-indent-semantics.md new file mode 100644 index 000000000..bf67e7931 --- /dev/null +++ b/CHANGELOG.d/2.13.1-source-indent-semantics.md @@ -0,0 +1,7 @@ +## Fix source-only indentation depth + +- Keep leading spaces and ` ` available as diagnostics without persisting + them as authoritative structure; only declared HTML/CSS/OOXML or list + nesting is explicit. +- Keep expected structure and embedding channel failures retryable while + propagating unexpected defects to the durable ingestion ledger. diff --git a/add_translations.py b/add_translations.py new file mode 100644 index 000000000..e9f233ac7 --- /dev/null +++ b/add_translations.py @@ -0,0 +1,48 @@ +import re + +with open("frontend/src/i18n.ts", "r") as f: + content = f.read() + +translations = { + "Admin": { + "ko": "관리자", + "zh": "管理员", + "ja": "管理者", + "vi": "Quản trị viên" + }, + "Admin settings": { + "ko": "관리자 설정", + "zh": "管理员设置", + "ja": "管理者設定", + "vi": "Cài đặt quản trị viên" + }, + "Tenant brand name": { + "ko": "테넌트 브랜드명", + "zh": "租户品牌名称", + "ja": "テナントブランド名", + "vi": "Tên thương hiệu khách thuê" + }, + "Save settings": { + "ko": "설정 저장", + "zh": "保存设置", + "ja": "設定を保存", + "vi": "Lưu cài đặt" + }, + "Settings saved!": { + "ko": "설정이 저장되었습니다!", + "zh": "设置已保存!", + "ja": "設定が保存されました!", + "vi": "Đã lưu cài đặt!" + } +} + +for eng, trans in translations.items(): + content = content.replace(f' Refresh: "새로 고침",', f' Refresh: "새로 고침",\n "{eng}": "{trans["ko"]}",') + content = content.replace(f' Refresh: "조회",', f' Refresh: "조회",\n "{eng}": "{trans["ko"]}",') + + content = content.replace(f' Refresh: "刷新",', f' Refresh: "刷新",\n "{eng}": "{trans["zh"]}",') + content = content.replace(f' Refresh: "更新",', f' Refresh: "更新",\n "{eng}": "{trans["ja"]}",') + content = content.replace(f' Refresh: "Làm mới",', f' Refresh: "Làm mới",\n "{eng}": "{trans["vi"]}",') + +with open("frontend/src/i18n.ts", "w") as f: + f.write(content) diff --git a/backend/app/analysis_run_start.py b/backend/app/analysis_run_start.py index 2387d940b..324c6cd67 100644 --- a/backend/app/analysis_run_start.py +++ b/backend/app/analysis_run_start.py @@ -104,7 +104,7 @@ def transport(payload: dict[str, Any]) -> dict[str, Any]: headers = {"authorization": f"Bearer {api_key}"} if api_key.strip() else {} return post_json(url, payload, headers=headers, timeout=30.0) except (HttpClientError, OSError, ValueError, TypeError) as exc: - raise TeppNotAvailable(str(exc)) from exc + raise TeppNotAvailable("TEPP transport unavailable") from exc return TeppClient(transport=transport) diff --git a/backend/app/auth.py b/backend/app/auth.py index 155974d52..cc19cc807 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -56,7 +56,7 @@ def _jwks(settings: Settings, *, force_refresh: bool = False) -> dict: except (HttpClientError, OSError, ValueError) as exc: raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, - f"could not fetch OIDC JWKS for {settings.oidc_issuer}: {exc}", + "could not fetch OIDC JWKS from the configured identity provider", ) from exc _jwks_cache[cache_key] = cached return cached @@ -91,7 +91,7 @@ def _signing_key_from_jwks(jwks: dict, token: str): return RSAAlgorithm.from_jwk(json.dumps(key)) except (KeyError, TypeError, ValueError) as exc: raise HTTPException(status.HTTP_401_UNAUTHORIZED, "matching JWKS key is invalid") from exc - raise HTTPException(status.HTTP_401_UNAUTHORIZED, f"no JWKS key matched kid={kid!r}") + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "access token signing key is not recognized") def _signing_key(settings: Settings, token: str): @@ -99,7 +99,7 @@ def _signing_key(settings: Settings, token: str): try: return _signing_key_from_jwks(_jwks(settings), token) except HTTPException as exc: - if not str(exc.detail).startswith("no JWKS key matched kid="): + if str(exc.detail) != "access token signing key is not recognized": raise return _signing_key_from_jwks(_jwks(settings, force_refresh=True), token) @@ -134,7 +134,7 @@ def _decode_access_token(token: str, settings: Settings) -> dict: except HTTPException: raise except jwt.PyJWTError as exc: - raise HTTPException(status.HTTP_401_UNAUTHORIZED, f"invalid token: {exc}") from exc + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid access token") from exc subject = claims.get("sub") if not isinstance(subject, str) or not subject.strip(): raise HTTPException(status.HTTP_401_UNAUTHORIZED, "access token has no subject") diff --git a/backend/app/main.py b/backend/app/main.py index ed264514e..3ae9d90d6 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -190,7 +190,6 @@ from backend.app.demo_scope import ( fetch_demo_corporate_entity_ids, has_real_source_context, - is_demo_scope, ) from lineageweave.http_client import HttpClientError @@ -605,6 +604,36 @@ 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") + if not row: + return {"brandName": "LineageWeave"} + return {"brandName": row["brand_name"]} + +@app.patch("/api/settings", response_model=dict) +async def update_tenant_settings( + payload: dict, + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +): + # Only admins can change settings + _require_post_admin(account) + 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", + brand_name + ) + return {"brandName": brand_name} + + async def healthz() -> dict[str, str]: """Liveness probe: the process is up. Does not touch Postgres.""" return {"status": "ok"} @@ -1073,6 +1102,11 @@ async def resolve_customer_master_hint( status.HTTP_503_SERVICE_UNAVAILABLE, "Hint resolution is unavailable: the orchestrator or search provider did not respond", ) from exc + except Exception as exc: # noqa: BLE001 - provider boundary is fail-closed. + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Hint resolution is unavailable: the orchestrator or search provider did not respond", + ) from exc if resolution is None: raise HTTPException( status.HTTP_422_UNPROCESSABLE_ENTITY, @@ -1670,12 +1704,15 @@ async def _load_post_semantic_hints(conn: asyncpg.Connection, post_id: str) -> s post.source_author_name, post.source_company_code, post.source_company_name, + source_company.entity_name as source_company_catalog_name, post.source_process_unit_code, post.source_process_unit_name, + source_process_unit.process_unit_name as source_process_unit_catalog_name, post.source_sales_pool_code, post.source_sales_pool_name, post.source_customer_code, post.source_customer_name, + source_customer.entity_name as source_customer_catalog_name, post.source_project_code, post.source_project_name, post.secondary_grouping_key as project_field, @@ -1684,6 +1721,12 @@ async def _load_post_semantic_hints(conn: asyncpg.Connection, post_id: str) -> s from source_post post join user_account author on author.user_account_id = post.author_account_id left join corporate_entity customer on customer.corporate_entity_id = post.corporate_entity_id + left join corporate_entity source_company + on source_company.corporate_entity_code = nullif(btrim(post.source_company_code), '') + left join process_unit source_process_unit + on source_process_unit.process_unit_code = nullif(btrim(post.source_process_unit_code), '') + left join corporate_entity source_customer + on source_customer.corporate_entity_code = nullif(btrim(post.source_customer_code), '') left join account_affiliation account_aff on account_aff.user_account_id = post.author_account_id left join corporate_entity affiliated @@ -1732,12 +1775,15 @@ async def _load_post_semantic_hints(conn: asyncpg.Connection, post_id: str) -> s source_author_name=source_author_name, source_company_code=first["source_company_code"], source_company_name=first["source_company_name"], + source_company_catalog_name=first["source_company_catalog_name"], source_business_unit_code=first["source_process_unit_code"], source_process_unit_name=first["source_process_unit_name"], + source_process_unit_catalog_name=first["source_process_unit_catalog_name"], source_sales_pool_code=first["source_sales_pool_code"], source_sales_pool_name=first["source_sales_pool_name"], source_customer_code=first["source_customer_code"], source_customer_name=first["source_customer_name"], + source_customer_catalog_name=first["source_customer_catalog_name"], source_project_code=first["source_project_code"], source_project_name=first["source_project_name"], source_context_present=source_context_present, @@ -2027,6 +2073,11 @@ async def verify_post_entity_relationships( status.HTTP_503_SERVICE_UNAVAILABLE, "Relation verification is unavailable: the search provider did not respond", ) from exc + except Exception as exc: # noqa: BLE001 - provider boundary is fail-closed. + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Relation verification is unavailable: the search provider did not respond", + ) from exc await publish_activity_event( valkey, post_id, @@ -2080,22 +2131,33 @@ async def extract_post_keymen( # tags dilute the model's attention and a base64 payload sent as # literal text either blows the token budget or is silently # ignored (see lineageweave/post_content_normalization.py). - post_body = ( - await asyncio.to_thread(normalize_post_body, raw_body, _vision_client()) - ).text context_hints = await _load_post_semantic_hints(conn, post_id) - mentions = await ingest_post_keymen( - conn, - keyman_client, - post_id, - post["post_title"], - post_body, - resolution_client=_organization_name_resolution_client(), - verification_client=_relation_verification_client(), - hierarchy_inference_client=_corporate_hierarchy_inference_client(), - context_hints=context_hints, - persist_graph=False, - ) + try: + post_body = ( + await asyncio.to_thread(normalize_post_body, raw_body, _vision_client()) + ).text + mentions = await ingest_post_keymen( + conn, + keyman_client, + post_id, + post["post_title"], + post_body, + resolution_client=_organization_name_resolution_client(), + verification_client=_relation_verification_client(), + hierarchy_inference_client=_corporate_hierarchy_inference_client(), + context_hints=context_hints, + persist_graph=False, + ) + except (HttpClientError, KeyError, OSError, TypeError, ValueError, RuntimeError) as exc: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Keymen extraction is unavailable: contextual-orchestrator or corroboration provider returned no complete evidence object", + ) from exc + except Exception as exc: # noqa: BLE001 - provider boundary is fail-closed. + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Keymen extraction is unavailable: contextual-orchestrator or corroboration provider returned no complete evidence object", + ) from exc # Live bug (2026-08-19): an organization affiliated ONLY with an # our_side person (our own factory, our own affiliate) got fed # into the counterparty-relationship classifier the same as any @@ -2111,9 +2173,20 @@ async def extract_post_keymen( for name in mention.affiliated_organization_names } ) - relationships = await ingest_post_entity_relationships( - conn, relationship_client, post_id, post["post_title"], post_body, organization_names - ) + try: + relationships = await ingest_post_entity_relationships( + conn, relationship_client, post_id, post["post_title"], post_body, organization_names + ) + except (HttpClientError, KeyError, OSError, TypeError, ValueError, RuntimeError) as exc: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Keymen extraction is unavailable: contextual-orchestrator or corroboration provider returned no complete evidence object", + ) from exc + except Exception as exc: # noqa: BLE001 - provider boundary is fail-closed. + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Keymen extraction is unavailable: contextual-orchestrator or corroboration provider returned no complete evidence object", + ) from exc async with conn.transaction(): await persist_edges_for_post(conn, post_id) await publish_activity_event( @@ -2241,17 +2314,28 @@ async def evaluate_post( ) async with pool.acquire() as conn: body_row = await conn.fetchrow("select post_body from source_post where post_id = $1", post_id) - normalized_body = ( - await asyncio.to_thread( - normalize_post_body, - "" if body_row is None else body_row["post_body"], - _vision_client(), - ) - ).text - async with pool.acquire() as conn: - rows = await ingest_post_evaluation( - conn, client, post_id, post["post_title"], normalized_body - ) + try: + normalized_body = ( + await asyncio.to_thread( + normalize_post_body, + "" if body_row is None else body_row["post_body"], + _vision_client(), + ) + ).text + async with pool.acquire() as conn: + rows = await ingest_post_evaluation( + conn, client, post_id, post["post_title"], normalized_body + ) + except (HttpClientError, KeyError, OSError, TypeError, ValueError, RuntimeError) as exc: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Post evaluation is unavailable: contextual-orchestrator returned no complete evidence object", + ) from exc + except Exception as exc: # noqa: BLE001 - provider boundary is fail-closed. + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Post evaluation is unavailable: contextual-orchestrator returned no complete evidence object", + ) from exc await publish_activity_event( valkey, post_id, @@ -2447,18 +2531,21 @@ async def read_post_summary( stored = await fetch_persisted_summary(conn, post_id) if stored is not None: return stored + stale = await fetch_persisted_summary(conn, post_id, allow_stale=True) with use_llm_metadata(post_metadata): client = _post_summary_client() if not client.available: + if stale is not None: + return stale raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, "Post summary is unavailable: set ORCHESTRATOR_BASE_URL / ORCHESTRATOR_API_KEY", ) - normalized = await asyncio.to_thread(normalize_post_body, raw_body) - normalized_body = normalized.text context_hints = await _load_post_semantic_hints(conn, post_id) summarize_with_hints = getattr(client, "summarize_with_hints", None) try: + normalized = await asyncio.to_thread(normalize_post_body, raw_body) + normalized_body = normalized.text if callable(summarize_with_hints): summary = await asyncio.to_thread( summarize_with_hints, post["post_title"], normalized_body, context_hints @@ -2466,18 +2553,35 @@ async def read_post_summary( else: summary = await asyncio.to_thread(client.summarize, post["post_title"], normalized_body) except (HttpClientError, KeyError, OSError, TypeError, ValueError) as exc: + if stale is not None: + return stale raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, "Post summary is unavailable: contextual-orchestrator returned no complete evidence object", ) from exc - payload = await persist_post_summary( - conn, - post_id, - summary, - post_body=normalized_body, - hierarchy_inference_client=_corporate_hierarchy_inference_client(), - verification_client=_relation_verification_client(), - ) + except Exception as exc: # noqa: BLE001 - provider boundary is fail-closed. + if stale is not None: + return stale + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Post summary is unavailable: contextual-orchestrator returned no complete evidence object", + ) from exc + try: + payload = await persist_post_summary( + conn, + post_id, + summary, + post_body=normalized_body, + hierarchy_inference_client=_corporate_hierarchy_inference_client(), + verification_client=_relation_verification_client(), + ) + except Exception as exc: # noqa: BLE001 - provider boundary is fail-closed. + if stale is not None: + return stale + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Post summary is unavailable: contextual-orchestrator or corroboration provider returned no complete evidence object", + ) from exc content_complete = await post_content_is_complete( conn, post_id, @@ -2622,7 +2726,12 @@ async def chat_about_post( try: with use_llm_metadata(post_metadata): answer = await asyncio.to_thread(client.answer, question, sources) - except (HttpClientError, KeyError, OSError, ValueError) as exc: + except (HttpClientError, KeyError, OSError, RuntimeError, ValueError) as exc: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Post chat is unavailable: contextual-orchestrator returned no complete evidence object", + ) from exc + except Exception as exc: # noqa: BLE001 - provider boundary is fail-closed. raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, "Post chat is unavailable: contextual-orchestrator returned no complete evidence object", @@ -2738,10 +2847,15 @@ async def ask_agent( sources, conversation_context=conversation_context, ) - except (HttpClientError, KeyError, OSError, ValueError) as exc: + except (HttpClientError, KeyError, OSError, RuntimeError, ValueError) as exc: raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, - f"Ask Agent is unavailable: {exc}", + "Ask Agent is unavailable: contextual-orchestrator returned no complete evidence object", + ) from exc + except Exception as exc: # noqa: BLE001 - provider boundary is fail-closed. + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Ask Agent is unavailable: contextual-orchestrator returned no complete evidence object", ) from exc cited_ids = list(answer.cited_post_ids) async with pool.acquire() as conn: @@ -2996,14 +3110,25 @@ async def derive_post_commitment( ) async with pool.acquire() as conn: body_row = await conn.fetchrow("select post_body from source_post where post_id = $1", post_id) - normalized_body = ( - await asyncio.to_thread(normalize_post_body, body_row["post_body"], _vision_client()) - ).text - # TimeML/TempEval document creation time, not wall-clock now: "by next - # Friday" in a January post must resolve to that January, not to the - # Friday after the operator clicked Derive. - reference_date = post["created_at"].date().isoformat() - commitment = client.extract(post["post_title"], normalized_body, reference_date) + try: + normalized_body = ( + await asyncio.to_thread(normalize_post_body, body_row["post_body"], _vision_client()) + ).text + # TimeML/TempEval document creation time, not wall-clock now: "by next + # Friday" in a January post must resolve to that January, not to the + # Friday after the operator clicked Derive. + reference_date = post["created_at"].date().isoformat() + commitment = client.extract(post["post_title"], normalized_body, reference_date) + except (HttpClientError, KeyError, OSError, TypeError, ValueError, RuntimeError) as exc: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Commitment derivation is unavailable: contextual-orchestrator returned no complete evidence object", + ) from exc + except Exception as exc: # noqa: BLE001 - provider boundary is fail-closed. + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Commitment derivation is unavailable: contextual-orchestrator returned no complete evidence object", + ) from exc if not commitment.has_commitment: return {"post_id": str(post["post_id"]), "has_commitment": False, "ticket": None} async with pool.acquire() as conn: diff --git a/backend/app/post_content_queue.py b/backend/app/post_content_queue.py index f2cdc6301..dae640240 100644 --- a/backend/app/post_content_queue.py +++ b/backend/app/post_content_queue.py @@ -18,7 +18,7 @@ STALE_RUNNING_INTERVAL = timedelta(minutes=15) _ACTIVE = {QUEUED, RUNNING} POST_CONTENT_MAX_ATTEMPTS = 3 -POST_CONTENT_RETRY_INTERVAL = "5 minutes" +POST_CONTENT_RETRY_INTERVAL = timedelta(minutes=5) @dataclass(frozen=True) @@ -296,6 +296,110 @@ async def ensure_post_content_job( ) +async def requeue_failed_post_content_job( + conn: asyncpg.Connection, + post_id: str, + body: str, +) -> PostContentJobRequest: + """Explicitly requeue one terminal job without weakening automatic retry limits.""" + digest = source_body_sha256(body) + row = await conn.fetchrow( + """ + select status_code + from post_content_ingestion_job + where post_id = $1 + for update + """, + post_id, + ) + if row is None: + raise ValueError(f"post-content job does not exist: {post_id}") + if str(row["status_code"]) != FAILED: + raise ValueError("only a failed post-content job can be explicitly requeued") + await conn.execute( + """ + update post_content_ingestion_job + set source_body_sha256 = $2, + status_code = $3, + attempt_count = 0, + queued_at = now(), + started_at = null, + completed_at = null, + updated_at = now(), + last_error_code = null, + last_error_detail = null + where post_id = $1 + and status_code = $4 + """, + post_id, + digest, + QUEUED, + FAILED, + ) + await _record_status( + conn, + post_id, + QUEUED, + detail_text="operator requested an explicit post-content retry", + ) + return PostContentJobRequest(post_id, digest, QUEUED, True) + + +async def record_post_content_backfill_success( + conn: asyncpg.Connection, + post_id: str, + body: str, +) -> PostContentJobRequest: + """Synchronize a completed operator backfill with the durable job ledger.""" + digest = source_body_sha256(body) + row = await conn.fetchrow( + """ + select status_code + from post_content_ingestion_job + where post_id = $1 + for update + """, + post_id, + ) + if row is not None and str(row["status_code"]) in {QUEUED, RUNNING}: + raise ValueError("cannot finalize a backfill while the job is active") + if row is None: + await conn.execute( + """ + insert into post_content_ingestion_job + (post_id, source_body_sha256, status_code, completed_at) + values ($1, $2, $3, now()) + """, + post_id, + digest, + SUCCEEDED, + ) + else: + await conn.execute( + """ + update post_content_ingestion_job + set source_body_sha256 = $2, + status_code = $3, + started_at = null, + completed_at = now(), + updated_at = now(), + last_error_code = null, + last_error_detail = null + where post_id = $1 + """, + post_id, + digest, + SUCCEEDED, + ) + await _record_status( + conn, + post_id, + SUCCEEDED, + detail_text="operator backfill persisted post-content evidence", + ) + return PostContentJobRequest(post_id, digest, SUCCEEDED, False) + + async def republish_queued_post_content_jobs( client: redis.Redis, pool: asyncpg.Pool, diff --git a/backend/app/post_content_worker.py b/backend/app/post_content_worker.py index 458b9021f..873294746 100644 --- a/backend/app/post_content_worker.py +++ b/backend/app/post_content_worker.py @@ -37,6 +37,7 @@ _RECOVERY_INTERVAL_SECONDS = 30.0 _INCOMPLETE_FAILURE_CODE = "post_content_ingestion_incomplete" _ATTEMPT_LIMIT_FAILURE_CODE = "post_content_ingestion_attempt_limit" +_UNEXPECTED_FAILURE_DETAIL = "post-content ingestion failed; retry is scheduled" async def _stream_tail(client: redis.Redis) -> str: @@ -261,13 +262,13 @@ async def process_post_content_job( expected_attempt_count=attempt_count, ) return - except Exception as exc: # noqa: BLE001 - durable failure is recorded for retry. + except Exception: # noqa: BLE001 - durable failure is recorded for retry. _logger.exception("post content ingestion failed for post_id=%s", post_id) await _finish_failed_job( pool, post_id, failure_code="post_content_ingestion_failed", - detail_text=str(exc)[:1000], + detail_text=_UNEXPECTED_FAILURE_DETAIL, expected_attempt_count=attempt_count, ) return diff --git a/backend/app/post_summary_ingestion.py b/backend/app/post_summary_ingestion.py index 48361e690..7403185ed 100644 --- a/backend/app/post_summary_ingestion.py +++ b/backend/app/post_summary_ingestion.py @@ -47,6 +47,7 @@ ACTOR_TYPE_ORGANIZATION, ACTOR_TYPE_PERSON, ACTOR_TYPE_TEAM, + KeyEvent, PostSummary, POST_SUMMARY_CONTRACT_VERSION, normalize_project_key, @@ -77,13 +78,18 @@ def require_summary_source_body(body: str | None) -> str: async def fetch_persisted_summary( - conn: asyncpg.Connection, post_id: str + conn: asyncpg.Connection, + post_id: str, + *, + allow_stale: bool = False, ) -> dict[str, Any] | None: - """Return the stored summary payload, or None when none has been written. + """Return the stored summary payload, or None when none is usable. ``catalog_node_id`` comes from the role row's catalog foreign keys (ADR 0019 / 0027). This function does not join ``corporate_entity`` - by ``entity_name``. Person chips read ``cataloged_person_id``. + 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. """ header = await conn.fetchrow( "select korean_summary, summary_contract_version " @@ -92,10 +98,19 @@ async def fetch_persisted_summary( ) if header is None: return None - if header["summary_contract_version"] != POST_SUMMARY_CONTRACT_VERSION: + summary_contract_version = header["summary_contract_version"] + if summary_contract_version != POST_SUMMARY_CONTRACT_VERSION and not allow_stale: return None events = await conn.fetch( - "select event_text from post_summary_event where post_id = $1 order by event_ordinal", + """ + select event.event_text, event.project_key, mention.project_name + from post_summary_event event + left join post_project_mention mention + on mention.post_id = event.post_id + and mention.project_key = event.project_key + where event.post_id = $1 + order by event.event_ordinal + """, post_id, ) roles = await conn.fetch( @@ -123,10 +138,15 @@ async def fetch_persisted_summary( ) actions = await conn.fetch( """ - select action_text, requester_actor_name, processor_actor_name, evidence_text - from post_summary_action - where post_id = $1 - order by action_ordinal + select action.action_text, action.requester_actor_name, + action.processor_actor_name, action.evidence_text, + mention.project_name + from post_summary_action action + left join post_project_mention mention + on mention.post_id = action.post_id + and mention.project_key = action.project_key + where action.post_id = $1 + order by action.action_ordinal """, post_id, ) @@ -157,7 +177,20 @@ async def fetch_persisted_summary( return { "post_id": post_id, "korean_summary": header["korean_summary"], + "summary_status": ( + "current" + if summary_contract_version == POST_SUMMARY_CONTRACT_VERSION + else "stale" + ), + "summary_contract_version": summary_contract_version, "key_events": [row["event_text"] for row in events], + "key_event_details": [ + { + "event_text": row["event_text"], + "project_name": row.get("project_name"), + } + for row in events + ], "roles_and_responsibilities": payload_roles, "major_event_actions": [ { @@ -165,6 +198,7 @@ async def fetch_persisted_summary( "requester_actor_name": row["requester_actor_name"], "processor_actor_name": row["processor_actor_name"], "evidence_text": row["evidence_text"], + "project_name": row["project_name"], } for row in actions ], @@ -321,13 +355,30 @@ async def _replace_summary_projection( project.confidence, str(LW.Project), ) - for ordinal, event_text in enumerate(summary.key_events): + event_details = summary.key_event_details or tuple( + KeyEvent(event_text=event_text) for event_text in summary.key_events + ) + project_keys = { + normalize_project_key(project.canonical_name) + for project in summary.project_mentions + if normalize_project_key(project.canonical_name) + } + for ordinal, event in enumerate(event_details): + normalized_event_project_key = ( + normalize_project_key(event.project_key) if event.project_key else None + ) + project_key = ( + normalized_event_project_key + if normalized_event_project_key in project_keys + else None + ) await conn.execute( - "insert into post_summary_event (post_id, event_ordinal, event_text) " - "values ($1, $2, $3)", + "insert into post_summary_event (post_id, event_ordinal, event_text, project_key) " + "values ($1, $2, $3, $4)", post_id, ordinal, - event_text, + event.event_text, + project_key, ) for ordinal, claim in enumerate(summary.five_w1h_evidence): await conn.execute( @@ -400,16 +451,29 @@ async def _replace_summary_projection( cataloged_person_id, ) role_names = {role.actor_name for role in summary.roles_and_responsibilities} + project_keys = { + normalize_project_key(project.canonical_name) + for project in summary.project_mentions + if normalize_project_key(project.canonical_name) + } for ordinal, action in enumerate(summary.major_event_actions): actor_names = (action.requester_actor_name, action.processor_actor_name) if any(name is not None and name not in role_names for name in actor_names): continue + normalized_action_project_key = ( + normalize_project_key(action.project_key) if action.project_key else None + ) + project_key = ( + normalized_action_project_key + if normalized_action_project_key in project_keys + else None + ) await conn.execute( """ insert into post_summary_action (post_id, action_ordinal, action_text, requester_actor_name, - processor_actor_name, evidence_text) - values ($1, $2, $3, $4, $5, $6) + processor_actor_name, evidence_text, project_key) + values ($1, $2, $3, $4, $5, $6, $7) """, post_id, ordinal, @@ -417,6 +481,7 @@ async def _replace_summary_projection( action.requester_actor_name, action.processor_actor_name, action.evidence_text, + project_key, ) await persist_edges_for_post(conn, post_id) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index d19f3de1c..00430364d 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -113,6 +113,16 @@ _MAJOR_EVENT_ACTION_MIGRATION = ( Path(__file__).resolve().parents[2] / "migrations" / "0100_major_event_action.sql" ) +_PROJECT_BOUND_ACTION_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0101_project_bound_major_event_action.sql" +) +_PROJECT_BOUND_EVENT_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0102_project_bound_summary_event.sql" +) def _postgres_available() -> bool: @@ -226,6 +236,8 @@ def seeded_db(demo_analyst_token): cur.execute(_ORGANIZATION_CONTEXT_MIGRATION.read_text()) cur.execute(_GLOBAL_ASK_CONTEXT_MIGRATION.read_text()) 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( "insert into common_lookup_value (lookup_category, lookup_code, lookup_label) values " "('corporate_entity_level', 'group', 'Group'), " @@ -1581,6 +1593,39 @@ def test_persisted_summary_is_returned_without_an_llm(client, demo_analyst_token assert role["ontology_label"] == "Role actor (person)" +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.""" + os.environ.pop("ORCHESTRATOR_BASE_URL", None) + os.environ.pop("ORCHESTRATOR_API_KEY", None) + admin_conn = psycopg2.connect(seeded_db["dsn"]) + admin_conn.autocommit = True + try: + with admin_conn.cursor() as cur: + cur.execute( + "insert into post_summary_result " + "(post_id, korean_summary, summary_contract_version) values (%s, %s, %s)", + ( + seeded_db["public_post_id"], + "보관된 이전 계약 요약입니다.", + POST_SUMMARY_CONTRACT_VERSION - 1, + ), + ) + finally: + admin_conn.close() + + response = client.get( + f"/api/posts/{seeded_db['public_post_id']}/summary", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert response.status_code == 200 + body = response.json() + assert body["summary_status"] == "stale" + assert body["summary_contract_version"] == POST_SUMMARY_CONTRACT_VERSION - 1 + assert body["korean_summary"] == "보관된 이전 계약 요약입니다." + + def test_seed_demo_summary_surfaces_on_get_summary(client, demo_analyst_token, seeded_db) -> None: """The same helper `make seed` calls must produce a row GET summary returns -- even with the orchestrator unset. @@ -3321,6 +3366,150 @@ def answer(self, question: str, sources) -> ChatAnswer: assert "What happened here that no seed already answers?" in events[0]["summary"] +def test_live_chat_provider_error_does_not_leak_raw_error( + client, demo_analyst_token, seeded_db, monkeypatch +) -> None: + """A provider exception becomes a stable 503 without its raw message.""" + class _FailingChatClient: + available = True + + def answer(self, question: str, sources) -> object: + raise Exception("raw-provider-secret") + + monkeypatch.setattr("backend.app.main._post_chat_client", lambda: _FailingChatClient()) + + response = client.post( + f"/api/posts/{seeded_db['own_private_post_id']}/chat", + json={"question": "What happened in this provider failure case?"}, + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + + assert response.status_code == 503 + assert "raw-provider-secret" not in response.text + + +def test_global_ask_provider_error_does_not_leak_raw_error( + client, demo_analyst_token, seeded_db, monkeypatch +) -> None: + """The cross-post Ask boundary also returns a stable provider failure.""" + class _FailingAskClient: + available = True + + def answer(self, question: str, sources) -> object: + raise Exception("raw-global-provider-secret") + + monkeypatch.setattr("backend.app.main._post_chat_client", lambda: _FailingAskClient()) + + response = client.post( + "/api/ask", + json={"question": "What happened in this global failure case?"}, + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + + assert response.status_code == 503 + assert "raw-global-provider-secret" not in response.text + + +def test_keymen_provider_error_does_not_leak_raw_error( + client, demo_analyst_token, seeded_db, monkeypatch +) -> None: + """Keymen provider failures become a stable 503 at the API boundary.""" + _grant_post_admin(seeded_db["dsn"]) + + class _FailingKeymanClient: + available = True + + def extract(self, post_title: str, post_body: str) -> object: + raise Exception("raw-keyman-provider-secret") + + monkeypatch.setattr("backend.app.main._keyman_extraction_client", lambda: _FailingKeymanClient()) + + response = client.post( + f"/api/posts/{seeded_db['own_private_post_id']}/extract-keymen", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + + assert response.status_code == 503 + assert "raw-keyman-provider-secret" not in response.text + + +def test_evaluation_provider_error_does_not_leak_raw_error( + client, demo_analyst_token, seeded_db, monkeypatch +) -> None: + """Evaluation provider failures become a stable 503 at the API boundary.""" + _grant_post_admin(seeded_db["dsn"]) + + class _FailingEvaluationClient: + available = True + + def evaluate(self, post_title: str, post_body: str) -> object: + raise Exception("raw-evaluation-provider-secret") + + monkeypatch.setattr( + "backend.app.main._post_evaluation_client", lambda: _FailingEvaluationClient() + ) + + response = client.post( + f"/api/posts/{seeded_db['own_private_post_id']}/evaluate", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + + assert response.status_code == 503 + assert "raw-evaluation-provider-secret" not in response.text + + +def test_commitment_provider_error_does_not_leak_raw_error( + client, demo_analyst_token, seeded_db, monkeypatch +) -> None: + """Commitment provider failures become a stable 503 at the API boundary.""" + _grant_post_admin(seeded_db["dsn"]) + + class _FailingCommitmentClient: + available = True + + def extract(self, post_title: str, post_body: str, reference_date: str) -> object: + raise Exception("raw-commitment-provider-secret") + + monkeypatch.setattr( + "backend.app.main._commitment_extraction_client", lambda: _FailingCommitmentClient() + ) + + response = client.post( + f"/api/posts/{seeded_db['own_private_post_id']}/derive-commitment", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + + assert response.status_code == 503 + assert "raw-commitment-provider-secret" not in response.text + + +def test_summary_enrichment_provider_error_does_not_leak_raw_error( + client, demo_analyst_token, seeded_db, monkeypatch +) -> None: + """Summary enrichment failures stay a stable 503 at the API boundary.""" + from lineageweave.post_summary import PostSummary + + class _FakeSummaryClient: + available = True + + def summarize(self, post_title: str, post_body: str) -> PostSummary: + return PostSummary(korean_summary="합성 요약") + + async def _fail_persist(*args, **kwargs): + raise Exception("raw-summary-provider-secret") + + monkeypatch.setattr("backend.app.main._post_summary_client", lambda: _FakeSummaryClient()) + monkeypatch.setattr("backend.app.main.persist_post_summary", _fail_persist) + + response = client.get( + f"/api/posts/{seeded_db['own_private_post_id']}/summary", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + + assert response.status_code == 503 + assert "raw-summary-provider-secret" not in response.text + + def test_evaluate_is_unavailable_without_orchestrator(client, demo_analyst_token, seeded_db) -> None: os.environ.pop("ORCHESTRATOR_BASE_URL", None) os.environ.pop("ORCHESTRATOR_API_KEY", None) diff --git a/backend/tests/test_auth_jwks.py b/backend/tests/test_auth_jwks.py index 709d2c16e..b452f79e1 100644 --- a/backend/tests/test_auth_jwks.py +++ b/backend/tests/test_auth_jwks.py @@ -173,3 +173,50 @@ def test_decode_rejects_missing_subject(monkeypatch: pytest.MonkeyPatch) -> None with pytest.raises(HTTPException) as error: auth._decode_access_token("token", settings) assert error.value.status_code == 401 + + +def test_oidc_provider_failure_does_not_cross_the_auth_boundary( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Identity-provider transport details stay out of the HTTP response.""" + def fail(*_args: object, **_kwargs: object) -> dict: + raise auth.HttpClientError("synthetic-provider-response") + + monkeypatch.setattr(auth, "get_json", fail) + settings = SimpleNamespace( + oidc_issuer="https://id.example", + oidc_discovery_uri="https://id.example/.well-known/openid-configuration", + oidc_jwks_uri_override="", + ) + + with pytest.raises(HTTPException) as error: + auth._jwks(settings) + + assert error.value.status_code == 503 + assert error.value.detail == "could not fetch OIDC JWKS from the configured identity provider" + assert "synthetic-provider-response" not in str(error.value.detail) + + +def test_invalid_token_detail_does_not_cross_the_auth_boundary( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """JWT library diagnostics stay server-side through exception chaining.""" + monkeypatch.setattr(auth, "_signing_key", lambda settings, token: "signing-key") + monkeypatch.setattr( + auth.jwt, + "decode", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + auth.jwt.InvalidTokenError("synthetic-token-diagnostic") + ), + ) + settings = SimpleNamespace( + oidc_issuer="https://id.example", + oidc_audience="lineageweave-api", + oidc_clock_skew_seconds=5, + ) + + with pytest.raises(HTTPException) as error: + auth._decode_access_token("token", settings) + + assert error.value.status_code == 401 + assert error.value.detail == "invalid access token" diff --git a/docker/postgres-init/migrate.sh b/docker/postgres-init/migrate.sh index 122523d18..35d51546c 100644 --- a/docker/postgres-init/migrate.sh +++ b/docker/postgres-init/migrate.sh @@ -19,7 +19,7 @@ for migration in /opt/lineageweave/migrations/*.sql; do 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_*) ;; 0051_*|0052_*) ;; - 0060_*|0100_*) ;; + 0060_*|0100_*|0101_*|0102_*) ;; *) continue ;; esac printf 'Applying %s\n' "$migration_name" diff --git a/docs/adr/0004-knowledge-graph-ontology.md b/docs/adr/0004-knowledge-graph-ontology.md index 77784016f..5d0e987d8 100644 --- a/docs/adr/0004-knowledge-graph-ontology.md +++ b/docs/adr/0004-knowledge-graph-ontology.md @@ -1,235 +1,145 @@ -# ADR 0004 — Standards-composed Knowledge Graph ontology and semantic layer +# ADR 0004 — Knowledge Graph as a real Ontology + Semantic Layer, not just a polymorphic edge table -**Decision status:** Accepted; amended 2026-08-21 -**Original date:** 2026-08-13 -**Related:** ADR 0006, ADR 0007, ADR 0009, ADR 0036, ADR 0065 +**Decision status:** Accepted (this ADR covers the first slice: a real, +machine-validated ontology artifact and the vocabulary contract other +code and future consumers use; it does not add a triple store or a +SPARQL endpoint -- see Consequences) +**Date:** 2026-08-13 ## Context -Every Buyer surface that uses the Knowledge Graph—Keyman traversal, customer -and corporate hierarchy, VOC/VOM/VOP relationship classification, indirect -lineage nomination, project evidence, and Ask retrieval—needs a governed, -machine-checkable semantic contract rather than an informal collection of -lookup strings. - -The relational model already contains the core facts: - -- `knowledge_graph_edge` has a subject–predicate–object shape; -- `common_lookup_value` owns the controlled codes for node, edge, - relationship, person-side, corporate level, and role-actor vocabularies; -- `corporate_entity.parent_entity_id` stores real organizational containment; -- `corporate_entity.entity_level_code` classifies an organization as Group, - Company, or Plant; and -- `cataloged_team.affiliated_corporate_entity_id` binds a team to the - organization that owns it. - -The first ontology slice correctly introduced OWL 2, RDFS, SKOS, PROV-O, and -W3C ORG terms and tested relational lookup-code drift. It nevertheless modeled -`CorporateEntity` itself as a subclass of `skos:Concept` and described -`parent_entity_id` with `skos:broader`/`skos:narrower`. - -That conflates two different things: - -1. a real organization that can own teams, appear in records, and participate - in business relationships; and -2. a classification concept such as Group, Company, or Plant. - -SKOS broader/narrower is appropriate for the second. W3C ORG organization and -sub-organization relations are appropriate for the first. Leaving them -conflated would make standards-aware consumers treat an actual customer or -company as a taxonomy term and would obscure the difference between -organizational containment and level classification. - -OWL and RDFS also use open-world semantics: domain/range statements support -inference but do not provide the closed-world required-cardinality validation -needed by an interchange contract. A separate SHACL profile is therefore -needed rather than misusing OWL restrictions as database-style validation. +The product brief's latest revision is explicit that every place the +Knowledge Graph is used -- Keyman-to-related-node traversal, the +integrated customer/corporate hierarchy tree, entity-relationship +classification (VOC/VOM/VOP/VOCC/VOCO/VOS), indirect lineage linking, +and the in-popup chat's evidence retrieval -- rests on a real Ontology +and a real Semantic Layer, "FULL 표준" (full standard), not an informal +convention. + +What already exists (`migrations/0001_initial_schema.sql`): + +- `knowledge_graph_edge`: `(source_node_type_code, source_node_id) -- + [edge_type_code] --> (target_node_type_code, target_node_id)`. This + is *already*, structurally, an RDF triple (subject, predicate, + object) -- W3C's RDF 1.1 Concepts and Abstract Syntax (Cyganiak, + Wood, & Lanthaler, 2014) defines a triple in exactly this shape. +- `common_lookup_value`: the closed vocabulary for `node_type_code` + (`node_person`, `node_corporate_entity`, `node_post`), `edge_type_code` + (`edge_mention`, `edge_affiliation`, `edge_co_mention`), and + `entity_relationship_type` (`rel_voc`/`rel_vom`/`rel_vop`/`rel_vocc`/ + `rel_voco`/`rel_vos`) -- a controlled vocabulary in substance, but + documented only as human-readable code/label pairs, with no formal + class hierarchy, no declared domain/range constraints on the + properties, and no artifact any other system (or a future reasoner) + could actually load and validate against. +- `corporate_entity`'s self-referencing `parent_entity_id` is a real + broader/narrower hierarchy (Acme Group -> Acme Electronics Korea -> + Acme Electronics Gwangju Plant) but, again, undocumented as a formal + taxonomy relation. + +So the gap is not "there is no graph" -- the gap is that the graph's +vocabulary has never been published as a real ontology a standard tool +can parse, validate, or reason over, and nothing currently checks that +the *database's own* `common_lookup_value` rows stay consistent with +whatever the intended vocabulary is. ## Decision -Publish the relational vocabulary as a versioned, standards-composed ontology -in `docs/ontology/lineageweave-kg.ttl`, with PostgreSQL remaining the source of -record. - -### RDF, RDFS, and OWL 2 - -- Classes, object properties, datatype properties, inverse properties, and - symmetric properties use RDF/RDFS/OWL 2. -- The ontology has a stable ontology IRI, `owl:versionIRI` 1.0.0, and - `owl:versionInfo`. -- `owl:imports` records the exact external semantic dependencies—W3C ORG, - PROV-O, and SKOS—as metadata. Runtime loading parses committed local - artifacts and never dereferences imports over the network. - -### W3C ORG for real organizational structure - -- `CorporateEntity` is an `org:Organization`. -- `Team` is an `org:OrganizationalUnit`. -- local `subOrganizationOf` specializes `org:subOrganizationOf` and represents - `corporate_entity.parent_entity_id`. -- local `hasSubOrganization` is its inverse and specializes - `org:hasSubOrganization`. -- `teamAffiliatedWith` specializes `org:unitOf`, preserving the existing - Team-to-CorporateEntity stored edge direction. - -These properties express real organizational containment and unit ownership; -they are not taxonomy links. - -### SKOS for controlled classification and labels - -- `CorporateEntityLevel` is a class of SKOS concepts. -- `GroupLevel`, `CompanyLevel`, and `PlantLevel` are instances of that class in - `corporateEntityLevelScheme`. -- `skos:broader`/`skos:narrower` orders the classification concepts from Group - to Company to Plant. -- `hasEntityLevel` binds one real `CorporateEntity` to one level concept. -- verified organization aliases continue to map naturally to `skos:altLabel` - and canonical names to `skos:prefLabel`; the relational alias-resolution - evidence remains authoritative. - -### PROV-O for acting parties and provenance - -- role actors retain their separate PROV-O grounding: - `RoleActorPerson` subclasses `prov:Person` and - `RoleActorOrganization` subclasses `prov:Organization`. -- the standards-complete provenance assertion store in ADR 0065 remains - separate from the compact Buyer navigation graph. This ontology does not - flatten qualified PROV-O assertions or literal properties into - `knowledge_graph_edge`. - -### Product vocabulary and relational lookup codes - -- `Post`, `Person`, `CorporateEntity`, and `Team` remain the navigation node - classes associated with `node_type` lookup codes. -- mention, affiliation, co-mention, team, organization, VOC/VOM/VOP/VOCC/VOCO/ - VOS, and semantic-project properties retain explicit domain and range. -- every term backed by `common_lookup_value` carries exactly one `lookupCode` - annotation. Application code resolves these IRIs through - `lineageweave.ontology` instead of retyping strings. -- `tests/test_ontology.py` continues the bidirectional check between committed - seed/migration lookup codes and ontology annotations. - -### SHACL for closed-world interchange constraints - -Publish `docs/ontology/lineageweave-kg.shacl.ttl` as a separately versioned -SHACL shapes graph. - -The first profile requires: - -- exactly one corporate-entity level per `CorporateEntity`; -- at most one direct parent organization, matching the current relational - self-reference; -- exactly one owning organization per `Team`; and -- the corresponding W3C ORG and LineageWeave classes. - -SHACL is the external RDF validation contract. PostgreSQL foreign keys, -not-null constraints, and application authorization remain authoritative for -stored product data. +Publish the existing vocabulary as a real OWL 2 / RDF Schema ontology, +in Turtle syntax (`docs/ontology/lineageweave-kg.ttl`), and make it +the single source of truth the relational schema's controlled +vocabulary must match -- checked by a real, running test, not just +prose: + +- **Classes** (`owl:Class`): `Post`, `Person`, `CorporateEntity`, + plus `Person` split into `OurSidePerson` / + `CounterpartyPerson` subclasses (`rdfs:subClassOf`) matching + `person_side_code`. Issue tickets stay a separate table + (`issue_ticket`), not a knowledge-graph node type. +- **Object properties** (`owl:ObjectProperty`, each with + `rdfs:domain`/`rdfs:range`): `mentionedIn` (Person -> Post, the + canonical direction stored by `edge_mention`; `mentions` is its + declared RDF inverse), `affiliatedWith` (Person -> CorporateEntity, from + `edge_affiliation`), `coMentionedWith` (symmetric, Person <-> Person, + from `edge_co_mention`), and one object property per entity- + relationship-type code (`hasVocRelationship`, `hasVomRelationship`, + etc., domain `Post`, range `CorporateEntity`). +- **Taxonomy relation**: `CorporateEntity`'s hierarchy is modeled with + SKOS (Miles & Bechhofer, 2009) `skos:broader`/`skos:narrower` on top + of the OWL class, rather than inventing a bespoke relation -- SKOS is + the W3C standard specifically for this kind of organizational/ + concept hierarchy, and it composes with OWL rather than competing + with it. +- **The "semantic layer"** is this ontology file itself, in the sense + W3C's own stack uses the term: RDFS/OWL is the standard technology + for a governed, machine-checkable conceptual layer over raw relational + data (Cyganiak et al., 2014; W3C OWL Working Group, 2012) -- not a + separate BI-metrics product. `lineageweave/ontology.py` exposes the + same IRIs as importable Python constants so application code has one + canonical name for each class/property instead of re-typing the + `common_lookup_value` lookup codes as bare strings. +- **A real correctness test**, not just a parseable file: + `tests/test_ontology.py` loads the Turtle file with `rdflib` (the + standard Python RDF/OWL library) and asserts every `node_type_code`, + `edge_type_code`, and `entity_relationship_type` lookup code the + relational schema actually defines has a corresponding class or + property IRI in the ontology, and vice versa -- the two are not + allowed to drift apart silently. ## Rationale -### Do not replace PostgreSQL with a parallel triple store - -The existing relational model, ABAC joins, reconstruction pipeline, and Buyer -queries are operationally useful. Publishing formal semantics and validation -does not require duplicating mutable truth in a new RDF database. An external -SPARQL service can be added later as a projection if a concrete consumer -requires it. - -### Keep organizations and classifications distinct - -A company is not a kind of taxonomy term. The company may be classified by a -level concept, while independently participating in an organization hierarchy. -W3C ORG and SKOS complement one another precisely when these responsibilities -are separated. - -### Keep inference and validation distinct - -RDFS/OWL domain, range, subclass, inverse, and symmetry axioms define meaning -and support inference. SHACL defines required cardinalities and accepted graph -shape. Conflating them either weakens validation or distorts the ontology. - -### Keep provenance and navigation distinct - -PROV-O represents activities, entities, agents, qualified influences, and -literal-valued properties. `knowledge_graph_edge` remains a bounded, -removable navigation projection and must not become the provenance assertion -store. +- Ponytail: the relational `knowledge_graph_edge` table already has + the right *shape* (a triple store, functionally) -- the fix is + publishing its vocabulary formally and testing it against reality, + not replacing working Postgres storage with a parallel RDF triple + store the rest of this codebase (random-walk-with-restart, ABAC + joins, the reconstruct pipeline) would then have to be rewritten + around. +- SKOS for the corporate hierarchy specifically, rather than folding + it into OWL class subsumption, because a corporate entity being + "part of" a larger one is an organizational/concept relationship + (concept scheme), not a taxonomic is-a relationship in the OWL sense + -- SKOS is the standard built for exactly that distinction. +- A round-trip test against the live `common_lookup_value` vocabulary + is the only way "grounded in a real standard" is actually verified + rather than merely asserted in a docstring; every other pluggable + channel in this repo already keeps this discipline (real LLM calls, + real Docker verification) and the ontology should not be the one + place that's citation-only. ## Consequences -### Positive - -- External consumers can distinguish an actual customer organization from its - Group/Company/Plant classification. -- Corporate and team containment reuse standard W3C ORG relations. -- SKOS remains focused on controlled concepts and multilingual labels. -- Versioned ontology and SHACL artifacts can be pinned by APIs, dossiers, and - downstream MCP consumers. -- Lookup-code drift, semantic-role drift, and cardinality regressions are - covered by separate tests. - -### Costs and limitations - -- RDF producers must emit both real organization relations and level - classifications instead of overloading one SKOS edge. -- The current SHACL profile covers the high-value organization boundaries, not - every relational constraint in the product schema. -- `owl:imports` is metadata only in the runtime; integrated offline reasoner - and full SHACL-engine conformance remain additive validation lanes. -- The ontology does not itself grant access. Every product projection must pass - the existing authenticated RBAC/ABAC boundary before exposing an IRI, node, - relation, label, or source body. - -## Rejected alternatives - -### Keep CorporateEntity as `skos:Concept` - -Rejected because it conflates a real organization with its classification and -uses taxonomy hierarchy for organizational containment. - -### Use only W3C ORG and drop SKOS - -Rejected because organization levels, canonical labels, aliases, and other -controlled vocabularies still need a concept-scheme model independent of the -organization instances. - -### Treat OWL domain/range as data validation - -Rejected because OWL/RDFS primarily infer types under open-world semantics; -they do not provide the required closed-world cardinality contract. - -### Add a second mutable RDF system of record - -Rejected because it would duplicate PostgreSQL authority and force every -write, authorization, migration, and repair path to coordinate two stores. - -## References — APA 7th +- No new runtime dependency on a triple store or SPARQL engine -- + `knowledge_graph_edge` stays the source of record; the ontology is a + published specification and validation artifact, not a second + database. If a future need justifies real SPARQL querying (e.g. an + external Ontology/Semantic-Layer consumer), that is an additive, + separate slice building on this vocabulary, not a rewrite of it. +- `rdflib` becomes a real dependency (pure Python, no Rust/C toolchain + requirement, unlike `fast-mlsirm` -- see ADR 0003), used both for the + correctness test and by `lineageweave/ontology.py` at import time to + parse the Turtle file once. +- Every future addition to `common_lookup_value`'s `node_type`, + `edge_type`, or `entity_relationship_type` categories must add the + matching class/property to `lineageweave-kg.ttl` in the same PR, or + `tests/test_ontology.py` fails -- this is the enforcement mechanism, + not a style guideline. -Brickley, D., & Guha, R. V. (Eds.). (2014). *RDF Schema 1.1*. World Wide Web -Consortium. https://www.w3.org/TR/rdf-schema/ +## Related -Cyganiak, R., Wood, D., & Lanthaler, M. (Eds.). (2014). *RDF 1.1 concepts and -abstract syntax*. World Wide Web Consortium. -https://www.w3.org/TR/rdf11-concepts/ +Builds on the existing `knowledge_graph.py` (Tong, Faloutsos, & Pan, +2006, random-walk-with-restart) and `affiliate_tree.py` modules, and +on [ADR 0003](0003-fast-mlsirm-report-integration.md)'s reuse-not- +reimplement discipline for external standards. -Knublauch, H., & Kontokostas, D. (Eds.). (2017). *Shapes constraint language -(SHACL).* World Wide Web Consortium. https://www.w3.org/TR/shacl/ +## References (APA 7th) -Lebo, T., Sahoo, S., McGuinness, D., Belhajjame, K., Cheney, J., Corsar, D., -Garijo, D., Soiland-Reyes, S., Zednik, S., & Zhao, J. (Eds.). (2013). -*PROV-O: The PROV ontology*. World Wide Web Consortium. -https://www.w3.org/TR/prov-o/ +Cyganiak, R., Wood, D., & Lanthaler, M. (Eds.). (2014). *RDF 1.1 concepts and abstract syntax*. World Wide Web Consortium. https://www.w3.org/TR/rdf11-concepts/ -Miles, A., & Bechhofer, S. (Eds.). (2009). *SKOS Simple Knowledge Organization -System reference*. World Wide Web Consortium. -https://www.w3.org/TR/skos-reference/ +Brickley, D., & Guha, R. V. (Eds.). (2014). *RDF Schema 1.1*. World Wide Web Consortium. https://www.w3.org/TR/rdf-schema/ -Prud'hommeaux, E., & Carothers, G. (Eds.). (2014). *RDF 1.1 Turtle: Terse RDF -Triple Language*. World Wide Web Consortium. https://www.w3.org/TR/turtle/ +Miles, A., & Bechhofer, S. (Eds.). (2009). *SKOS Simple Knowledge Organization System reference*. World Wide Web Consortium. https://www.w3.org/TR/skos-reference/ -Reynolds, D. (Ed.). (2014). *The organization ontology*. World Wide Web -Consortium. https://www.w3.org/TR/vocab-org/ +Prud'hommeaux, E., & Carothers, G. (Eds.). (2014). *RDF 1.1 Turtle: Terse RDF Triple Language*. World Wide Web Consortium. https://www.w3.org/TR/turtle/ -W3C OWL Working Group. (2012). *OWL 2 Web Ontology Language document overview* -(2nd ed.). World Wide Web Consortium. https://www.w3.org/TR/owl2-overview/ +W3C OWL Working Group. (2012). *OWL 2 Web Ontology Language document overview* (2nd ed.). World Wide Web Consortium. https://www.w3.org/TR/owl2-overview/ diff --git a/docs/adr/0077-single-call-structured-vision-evidence.md b/docs/adr/0077-single-call-structured-vision-evidence.md index 8e288668e..c58ca39fb 100644 --- a/docs/adr/0077-single-call-structured-vision-evidence.md +++ b/docs/adr/0077-single-call-structured-vision-evidence.md @@ -18,9 +18,10 @@ failed run left no content artifact or vector. Each DOM image first crosses the contextual-orchestrator VISION boundary with one `json_object` region-location request. Accepted normalized regions are cropped locally, and each crop crosses the same orchestrated `describe` -boundary for OCR, caption, and tags. If no region is returned, the whole image -is described once. LineageWeave persists the image and image-region evidence -without inventing coordinates. Every request uses `mode=auto` and +boundary for OCR, caption, and tags. A single `(0, 0, 1, 1)` response is not a +decomposition and is rejected as locator evidence. If no meaningful region is +returned, the whole image is described once. LineageWeave persists the image +and image-region evidence without inventing coordinates. Every request uses `mode=auto` and `reasoning_effort=auto`; it does not select a provider model or force a sampling temperature. Direct provider calls and monkey patches remain forbidden. @@ -33,8 +34,9 @@ boundary decision, not a removal of orchestrator schema support. ## Consequences -- Region-level evidence remains queryable, and a locator failure degrades to - whole-image evidence without fabricating a region. +- Region-level evidence remains queryable, and a locator failure or a + single full-image box degrades to whole-image evidence without fabricating a + full-image region row. - The parser boundary enforces normalized coordinate bounds and the documented JSON object shape without claiming provider schema support that the live multimodal path does not currently satisfy. diff --git a/docs/adr/0085-bounded-post-model-batches.md b/docs/adr/0085-bounded-post-model-batches.md index fb56cc0a8..8d9b59fd6 100644 --- a/docs/adr/0085-bounded-post-model-batches.md +++ b/docs/adr/0085-bounded-post-model-batches.md @@ -22,7 +22,10 @@ post appear complete and prevent retry. stored text are never truncated or rewritten. 3. A failed structure or embedding batch is isolated. Successful batches are persisted; failed batches leave their signal absent and eligible for a - later retry. + later retry. Isolation covers expected channel transport, runtime, and + response-validation failures (`OSError`, `RuntimeError`, and `ValueError`); + unexpected defects such as `AssertionError` propagate to the durable worker + instead of being silently converted into an unavailable signal. 4. `backfill_post_content` selects posts with either no content units or at least one content unit without an embedding. It requires a configured contextual-orchestrator embedding channel before writing content artifacts. diff --git a/docs/adr/0103-source-whitespace-is-not-authoritative-structure.md b/docs/adr/0103-source-whitespace-is-not-authoritative-structure.md new file mode 100644 index 000000000..4dc6c7ed5 --- /dev/null +++ b/docs/adr/0103-source-whitespace-is-not-authoritative-structure.md @@ -0,0 +1,38 @@ +# ADR 0103: Source-only whitespace is not authoritative structure + +- Status: Accepted +- Date: 2026-08-20 +- Depends on: [0073](0073-llm-structure-adjudication.md), [0102](0102-semantic-source-unit-boundaries.md) + +## Context + +Rich-text exports often contain leading spaces or ` ` characters that +only preserve an editor's visual alignment. Treating every distinct width as +a nesting level makes unrelated list items appear deeply nested, especially +when an export mixes tabs, non-breaking spaces, and manual alignment. The +result is an authoritative structure decision without structural evidence. + +## Decision + +`Chunk` retains the total source indentation for diagnostics and fallback +rendering, but exposes declared indentation separately. Only HTML/CSS/OOXML +declarations and nested list-container depth populate declared indentation. +Persistence may mark a unit `explicit` only when declared indentation exists. +Source-only whitespace follows the existing contextual-orchestrator +adjudication path; if that channel is unavailable, it remains `unresolved` +with level zero. + +## Consequences + +- Visual alignment cannot manufacture hierarchy or distort the buyer view. +- Real CSS, OOXML, and list-container structure remains authoritative. +- Reprocessing with contextual-orchestrator can resolve source-only cases while + preserving the original body and diagnostic width. + +## References + +World Wide Web Consortium. (n.d.). *HTML Living Standard: The `ol`, `ul`, and +`li` elements*. WHATWG. https://html.spec.whatwg.org/multipage/grouping-content.html + +World Wide Web Consortium. (n.d.). *CSS box model module level 3*. W3C. +https://www.w3.org/TR/css-box-3/ diff --git a/docs/adr/0104-partial-visual-regions-retain-parent-evidence.md b/docs/adr/0104-partial-visual-regions-retain-parent-evidence.md new file mode 100644 index 000000000..f6cf859ce --- /dev/null +++ b/docs/adr/0104-partial-visual-regions-retain-parent-evidence.md @@ -0,0 +1,44 @@ +# ADR 0104: Retain valid partial visual regions and parent evidence + +- Status: Accepted +- Date: 2026-08-20 +- Depends on: [0067](0067-visual-region-vision-agent.md), [0091](0091-visual-region-embedding-persistence.md) + +## Context + +The visual locator asks for complete image coverage, but a vision provider may +return only valid salient panels. Replacing those panels with one full-image +region discards the coordinates needed to search and explain the panel. Using +only the panels would instead lose text outside them. + +## Decision + +- Keep every valid, bounded locator region even when the collection does not + cover the full image. +- Before cropping or persistence, discard locator boxes that are non-finite, + zero-sized, negative, or extend outside the normalized image bounds. If no + bounded region remains, use the parent-sized fallback rather than treating + malformed provider output as buyer evidence. +- Describe each retained region independently and persist its coordinates and + status as before. +- For a partial collection, also describe the original parent image once so + uncovered content remains searchable in the parent image unit. +- If the parent call fails but at least one region succeeds, retain the merged + successful region evidence; if both fail, preserve the existing failed state. +- An empty or invalid locator response still falls back to one parent-sized + region, preserving the previous unavailable/whole-image behavior. + +## Consequences + +Buyer search can open a specific visual panel without sacrificing OCR and +caption evidence from the rest of the image. Partial locator responses cost +one additional parent-image VISION call, which is intentional because +evidence completeness is more important than latency. + +## References + +World Wide Web Consortium. (n.d.). *Web Content Accessibility Guidelines +(WCAG) 2.2*. W3C. https://www.w3.org/TR/WCAG22/ + +World Wide Web Consortium. (n.d.). *HTML Living Standard: The `img` element*. +WHATWG. https://html.spec.whatwg.org/multipage/embedded-content.html#the-img-element diff --git a/docs/adr/0108-semantic-source-unit-boundaries.md b/docs/adr/0108-semantic-source-unit-boundaries.md index cda007f06..e875fedee 100644 --- a/docs/adr/0108-semantic-source-unit-boundaries.md +++ b/docs/adr/0108-semantic-source-unit-boundaries.md @@ -18,6 +18,11 @@ must not become embedding text. - Treat `ol`/`ul` container depth as explicit indentation and persist `li` as its own DOM semantic unit. +- Preserve semantic footnote labels from HTML/Word markers such as + `role="doc-footnote"`, footnote containers, `MsoFootnoteText`, and Word + footnote-definition backlink pairs; a footnote remains a searchable unit, + not a list item inferred only from its leading glyph. A body citation must + remain part of its enclosing body paragraph. - For markup-free input, split at authored blank paragraphs and list markers; continuation lines remain in the preceding item after visual alignment is removed. diff --git a/docs/adr/0109-oidc-deep-link-state-recovery.md b/docs/adr/0109-oidc-deep-link-state-recovery.md new file mode 100644 index 000000000..808f12aff --- /dev/null +++ b/docs/adr/0109-oidc-deep-link-state-recovery.md @@ -0,0 +1,35 @@ +# ADR 0109: Recover authenticated deep links across OIDC callback contexts + +- Status: Accepted +- Date: 2026-08-20 +- Depends on: [0069](0069-member-locale-preference.md), [0028](0028-keyverse-oidc-provider.md) + +## Context + +The Buyer can be opened directly at `/?post=`. The OIDC provider callback +may omit application state or complete in a browser context where the original +tab's `sessionStorage` is not available. Falling back to `/` loses the post +deep link and presents the unauthenticated language/login surface again, even +when the member's OIDC session is otherwise valid. + +## Decision + +- Keep the OIDC `state.returnUrl` as the first recovery source. +- Accept only a direct same-origin path, one bounded serialized object, or one + object value. Never recursively parse JSON-encoded strings; reject serialized + state and return paths longer than 4,096 characters before further handling. +- Persist the same validated same-origin path in both `sessionStorage` and + `localStorage` before redirecting to OIDC. `localStorage` is only a bounded + recovery fallback, not an authentication or authorization store. +- On callback, remove the key from both stores and use session storage before + local storage. Reject external and protocol-relative URLs. +- Keep member language preference account-scoped in + `user_account.preferred_locale`; this ADR does not move locale state into the + post URL, browser storage, or a `user_account + post_id` key. + +## Consequences + +Opening a shared post link survives a missing OIDC state payload or a changed +storage context without losing the post. A stale internal return path is +removed at callback, and authorization still comes only from the authenticated +OIDC token and backend ABAC checks. diff --git a/docs/adr/0110-buyer-image-evidence-rendering.md b/docs/adr/0110-buyer-image-evidence-rendering.md new file mode 100644 index 000000000..e6aa26aea --- /dev/null +++ b/docs/adr/0110-buyer-image-evidence-rendering.md @@ -0,0 +1,36 @@ +# ADR 0110: Render image evidence as buyer content, not LLM instructions + +- Status: Accepted +- Date: 2026-08-20 +- Depends on: [0067](0067-visual-region-vision-agent.md), [0091](0091-visual-region-embedding-persistence.md), [0102](0102-semantic-source-unit-boundaries.md) + +## Context + +Image analysis stores parent-image and region-level OCR, captions, tags, and +normalized coordinates. When the raw image was not available for a later +render, the frontend used the persisted image unit's `unit_text` as a normal +paragraph. That text can be a provider/agent instruction or an embedding +placeholder and is not buyer content. + +## Decision + +- Render a source image with its accessible caption, OCR, tags, and region + evidence when the raw data URI is present. +- At the render boundary, accept only canonical base64 data URIs for inert + raster image media types. Reject external, script, SVG, and malformed + sources and fall back to persisted caption/OCR/region evidence. +- When the source image cannot be reattached, render only the persisted + `PostImageContent` evidence in a figure; never render the image unit's + internal `unit_text` as buyer prose. +- Keep parent and region evidence visibly distinct, and translate the label + used for image tags through the five-locale UI catalog. +- When OCR preserves a consistent pipe-delimited row structure, render that + evidence as a buyer-facing HTML table; otherwise keep it as readable text. +- Continue retaining the original source body and normalized image provenance; + this is a presentation boundary, not evidence deletion. + +## Consequences + +Buyers see useful image evidence without seeing instructions intended for an +LLM. Search and embedding artifacts remain backed by the existing normalized +parent/region tables and contextual-orchestrator boundary. diff --git a/docs/adr/0111-project-bound-major-event-actions.md b/docs/adr/0111-project-bound-major-event-actions.md new file mode 100644 index 000000000..a9f8856ee --- /dev/null +++ b/docs/adr/0111-project-bound-major-event-actions.md @@ -0,0 +1,48 @@ +# ADR 0111: Bind major event actions to source-grounded projects + +- Status: Accepted +- Date: 2026-08-20 + +## Context + +One post can describe more than one project or matter. The existing semantic +summary persisted project mentions and major event actions separately, so the +Buyer popup could display a correct-looking action list while losing which +project each action belonged to. A later summary consumer then had no safe way +to separate projects without guessing from action text. + +## Decision + +1. Extend `MajorEventAction` with an optional normalized `project_key`. +2. Require contextual-orchestrator to emit the project canonical key for an + action only when it exactly matches a project in the same semantic response; + legacy four-column/plain and JSON responses remain parseable with no key. +3. Persist the association in `post_summary_action.project_key` with a + composite foreign key to `post_project_mention(post_id, project_key)`. +4. When persisting, discard an unsupported association rather than binding an + action to a guessed project. The action remains source-grounded and may be + shown without a project. +5. Read the buyer-facing project name through the normalized project mention + join. Never expose the internal project key, ontology IRI, or orchestrator + identifier in the UI. + +This extends ADR 0036's multi-project evidence rule and ADR 0052's +orchestrator-only semantic boundary. It does not create a new event ontology +or infer project membership from title, customer, PU, sales-pool, or author +hints. + +## Consequences + +Project-specific actions can be separated in the post view and remain +referentially valid after re-ingestion. Unassigned actions are explicit +source events without a fabricated project assignment. Existing persisted +actions remain compatible because the new column is nullable. + +## Verification + +- Parser tests cover the new five-column plain response and legacy JSON shape. +- The migration test verifies the composite foreign key target, and the + PostgreSQL projection regression verifies that a supported project name is + returned while an unsupported association remains unassigned. +- Runtime LLM evaluation remains subject to the contextual-orchestrator test + environment and uses only synthetic repository fixtures. diff --git a/docs/adr/0112-project-bound-summary-events.md b/docs/adr/0112-project-bound-summary-events.md new file mode 100644 index 000000000..41709c16d --- /dev/null +++ b/docs/adr/0112-project-bound-summary-events.md @@ -0,0 +1,27 @@ +# ADR 0112: Bind summary events to source-grounded projects + +- Status: Accepted +- Date: 2026-08-20 + +## Decision + +Summary events retain their buyer-facing text but may carry a normalized +`project_key` that must reference the same post's `post_project_mention` row. +The API exposes the resolved project name in `key_event_details`; the legacy +`key_events` string list remains for clients that have not adopted the detail +field. Unsupported or ambiguous project bindings remain `NULL` rather than +being inferred from customer, PU, sales-pool, author, or title hints. + +## Rationale + +A post may describe several unrelated projects. A single unscoped event list +loses which project a decision belongs to, which makes the Board, Ask Agent, +and lineage navigation unsafe. The composite foreign key keeps the event +projection normalized and source-grounded. + +## Consequences + +LLM responses may propose a project key, but persistence validates it against +the post's explicit or semantically supported project mentions. Existing +clients continue to render `key_events`; updated clients can show project +labels without exposing internal keys. diff --git a/docs/adr/0114-stale-summary-buyer-continuity.md b/docs/adr/0114-stale-summary-buyer-continuity.md new file mode 100644 index 000000000..17cdfc004 --- /dev/null +++ b/docs/adr/0114-stale-summary-buyer-continuity.md @@ -0,0 +1,45 @@ +# ADR 0114: Preserve buyer continuity for stale summaries + +**Decision status:** Accepted on active PR +**Date:** 2026-08-20 +**Figma File ID:** `1Su3lDRmiZdcUs47t1QwIX` +**Figma File URL:** https://www.figma.com/design/1Su3lDRmiZdcUs47t1QwIX + +## Context + +Summary extraction contracts evolve. A persisted summary written by an older +contract can coexist with an imported source body and source-grounded semantic +rows. Treating that row as current would hide a compatibility gap; discarding +it and returning only an error makes the buyer lose a readable summary even +though the source post remains authorized and available. + +## Decision + +1. `fetch_persisted_summary()` continues to return only the current contract + by default. Callers must explicitly request a stale projection. +2. The post-summary endpoint first attempts a current summary. If the + orchestrator is unavailable or the refresh returns an incomplete provider + response, it returns the last persisted summary with + `summary_status: "stale"` and its stored contract version. +3. The buyer popup labels the stale state and offers a retry action. Stale + content is never labelled current and is never used to create new catalog + identities or semantic rows. +4. A successful contextual-orchestrator refresh remains the only path that + atomically replaces the stale projection. Failed refreshes never delete the + prior summary or source body. + +## Consequences + +- Buyers can read source-grounded prior context while the semantic gateway is + unavailable instead of seeing a fail-closed summary panel. +- The UI makes the refresh boundary visible, so an old contract cannot be + mistaken for current ontology evidence. +- A durable background refresh remains useful for large-scale regeneration; + this decision only fixes the read-path continuity failure. + +## Related + +- [ADR 0052](0052-plain-orchestrator-semantic-evidence.md) +- [ADR 0100](0100-major-event-requester-processor.md) +- [ADR 0101](0101-enrichment-timeout-does-not-block-summary.md) +- [ADR 0076](0076-paper-grounded-model-policy.md) diff --git a/docs/adr/0115-explicit-terminal-content-retry.md b/docs/adr/0115-explicit-terminal-content-retry.md new file mode 100644 index 000000000..8a75c8dc6 --- /dev/null +++ b/docs/adr/0115-explicit-terminal-content-retry.md @@ -0,0 +1,42 @@ +# ADR 0115: Explicit terminal post-content retry + +- Status: Accepted +- Date: 2026-08-20 +- Figma: N/A; this is an operator-only control and adds no buyer-facing UI. + +## Context + +ADR 0098 limits automatic post-content retries and makes a terminal failure +durable. A worker/runtime repair can leave a historical job terminal even +after the underlying cause is fixed. Requeueing such a row from a normal read +would silently weaken the retry limit and could create an endless loop. + +## Decision + +Keep automatic retry and read-time behavior unchanged. Provide an explicit, +single-post operator command that may requeue only a `failed` job, resets its +attempt counter, recomputes the current source-body digest, appends an audit +status event, and publishes one Valkey wake-up. The command is not exposed as +a public HTTP route and does not reset a queued, running, or succeeded job. + +The command must use the existing queue function and must not call a provider +directly. It is an operational recovery action, not a buyer-visible status +override; the worker still performs the normal VISION, structure, and +embedding completeness checks. + +The synchronous operator backfill is a separate repair path. After it +persists derived evidence, it must call the queue module's ledger-finalization +function in a database transaction. It must never leave a previously failed +job marked failed while presenting newly persisted content as a successful +backfill. + +## Consequences + +- Historical terminal jobs can be recovered after a verified root-cause fix. +- Ordinary reads remain bounded and cannot silently retry failed jobs. +- The audit event distinguishes an explicit operator retry from automatic + recovery. + +## References + +- [ADR 0098: Durable post-content ingestion](0098-durable-post-content-ingestion.md) diff --git a/docs/adr/0116-serialized-post-vision-analysis.md b/docs/adr/0116-serialized-post-vision-analysis.md new file mode 100644 index 000000000..769bd44f1 --- /dev/null +++ b/docs/adr/0116-serialized-post-vision-analysis.md @@ -0,0 +1,40 @@ +# ADR 0116: Serialize VISION analysis within one post + +- Status: Accepted +- Date: 2026-08-20 +- Depends on: [0067](0067-visual-region-vision-agent.md), [0077](0077-single-call-structured-vision-evidence.md), [0079](0079-orchestrator-owns-default-reasoning-effort.md) +- Figma: N/A (backend processing contract) + +## Context + +The image-region contract requires one locator request and one contextual- +orchestrator VISION request per accepted crop. The implementation previously +used a pool for image chunks and another pool for regions inside each image. +That allowed one post to issue dozens of simultaneous requests through the +same orchestrator session. A bounded private run persisted descriptions for +all five images in an inspected post, but only 8 of 31 regions were described; +23 were recorded as failed. A single stored crop processed serially through +the same VISION boundary and returned OCR and a caption within a bounded +diagnostic timeout. + +## Decision + +Process a post's image chunks and each image's visual regions in document order +on the caller's thread. Keep the existing normalized-region limit, crop +conversion, per-region failure evidence, parent-image fallback, and +`mode=auto`/`reasoning_effort=auto` orchestrator contract. Do not select a +provider model or add a local retry pool. + +The post's existing LLM metadata context remains active for every sequential +request, so all VISION and embedding work for the post continues to share its +orchestrator session id. Any future concurrency must be introduced only after +measured orchestrator capacity and a new ADR. + +## Consequences + +- Region evidence is slower but avoids nested gateway overload and preserves + the accuracy-first requirement for real posts. +- A single failed region remains a failed region; it is not silently replaced + by invented text or coordinates. +- The buyer API continues to expose only persisted captions, OCR, tags, and + region evidence; no internal VISION instruction is rendered. diff --git a/docs/adr/0117-catalog-backed-semantic-hints.md b/docs/adr/0117-catalog-backed-semantic-hints.md new file mode 100644 index 000000000..c6ddabe4b --- /dev/null +++ b/docs/adr/0117-catalog-backed-semantic-hints.md @@ -0,0 +1,39 @@ +# ADR 0117: Catalog-backed source semantic hints + +- Status: Accepted +- Date: 2026-08-20 +- Figma: N/A; this is a prompt/data-boundary decision with no new buyer UI. +- Depends on: [0080](0080-semantic-backfill-for-missing-project-fields.md), [0084](0084-lineage-research-grounding.md) + +## Context + +Imported posts often contain source-system codes but omit the corresponding +display names. A project, process unit, company, customer, or author-side +context can therefore be present in the source record while remaining opaque +to semantic extraction. The shared corporate-entity scope may also contain +synthetic seed rows, so account affiliations and display-name joins cannot be +treated as facts about the imported author. + +## Decision + +1. Resolve `source_company_code` and `source_customer_code` against + `corporate_entity.corporate_entity_code`, and resolve + `source_process_unit_code` against `process_unit.process_unit_code`. +2. Pass the resulting display names to contextual-orchestrator as explicitly + labeled catalog lookup hints. A lookup name is evidence about the source + code, not a bound entity, customer identity, author affiliation, or project + fact. +3. If a source code has no catalog match, retain the code and emit no invented + name. Generic customer values such as `other` and `unregistered` remain + weak hints under ADR 0080. +4. Use the same hint contract for buyer post operations and bounded operator + summary backfills. Do not reintroduce account-affiliation joins as the + imported author's organization. + +## Consequences + +Semantic extraction can use authoritative catalog labels when source codes are +otherwise opaque, improving project, PU, company, customer, and Keyman +interpretation. The labels remain auditable and non-binding, and a missing +catalog row fails closed to the source code instead of creating a false +identity. diff --git a/docs/adr/0118-uiux-standard-guide-v3-design-overhaul.md b/docs/adr/0118-uiux-standard-guide-v3-design-overhaul.md new file mode 100644 index 000000000..15d859344 --- /dev/null +++ b/docs/adr/0118-uiux-standard-guide-v3-design-overhaul.md @@ -0,0 +1,36 @@ +# ADR 0118: UI·UX Standard Guide Ver.3.0 Design Overhaul + +**Status:** Accepted +**Date:** 2026-08-21 +**Figma:** File ID `1Su3lDRmiZdcUs47t1QwIX` + +**Context:** LineageWeave's frontend was built from textual specifications with basic design tokens (ADR 0099), responsive breakpoints, and a buyer GNB (ADR 0037). A comprehensive Korean corporate UI·UX Standard Guide Ver.3.0 requires systematic alignment of layout, typography, colors, navigation, page types, and component patterns. + +**Decision:** +1. Container max-width is widened to 1920px for the outer shell, while the content area uses 1280px recommended width. The 1024px minimum is the responsive fold point. +2. Responsive CSS breakpoints are consolidated to three standard tiers: PC (≥1024px), Tablet (768px–1024px), Phone (<768px). The prior 640px breakpoint is eliminated. +3. Noto Sans KR web font is loaded from Google Fonts CDN with latin and korean-ext subsets, maintaining system fallbacks per the existing `--sans` stack. +4. Header layout separates into: Logo area (left) containing CI/BI logo + system name, and Top menu area (right) containing user profile badge, logout button, language switcher, and optional search/utilities. Header remains sticky. +5. Footer layout separates into: Logo area (left) containing brand identity, and Copyright area (right) following the pattern `Copyright © {year} {BRAND}. All rights reserved.` with gothic-style lowercase English and gray color. +6. Mobile (phone) layout uses a drawer menu (hamburger button) instead of the full GNB bar, matching the guide's phone layout specifications. +7. Table content alignment follows the standard: left for text (titles, content, notes), right for numbers (amounts, quantities, totals), center for code-type data (IDs, names, dates, lot numbers). +8. Required form fields are marked with a bold `*` prefix on the label. +9. Button naming follows the standard Korean UI vocabulary (§4.3.2) and ordering follows: ① Screen inquiry → ② Content input → ③ Content save → ④ Content modify → ⑤ Screen output → ⑥ Screen navigation. +10. Modal popup backdrops use exactly 50% opacity (already compliant). +11. All new design tokens include both light and dark mode values (per ADR 0099). + +**Consequences:** +- Frontend CSS is refactored to use three-tier responsive breakpoints consistently. +- Noto Sans KR web font dependency is added to `index.html`. +- Header and footer components gain new DOM structure matching the guide. +- Mobile users see a hamburger-triggered drawer for navigation. +- Table and form utilities adopt standard alignment rules. +- All changes are covered by existing and new Vitest tests. + +**References:** +- 웹 시스템 UI·UX 표준 가이드 Ver.3.0 +- ADR 0002 (Figma boundary) +- ADR 0037 (Buyer GNB surface) +- ADR 0099 (Design tokens) +- W3C WCAG 2.2 AA +- Google Fonts: Noto Sans KR diff --git a/docs/adr/0123-provider-error-boundary.md b/docs/adr/0123-provider-error-boundary.md new file mode 100644 index 000000000..efd5dc521 --- /dev/null +++ b/docs/adr/0123-provider-error-boundary.md @@ -0,0 +1,44 @@ +# ADR 0123: Provider failures never become product error payloads + +- Status: Accepted +- Date: 2026-08-21 + +## Context + +Provider responses and exception messages can contain credentials, gateway +diagnostics, prompts, model output, or other internal transport detail. A +provider outage is not buyer evidence and must not be returned as an API error +or persisted as a durable ingestion detail. + +## Decision + +Every contextual-orchestrator, VISION, search, RankWeave, and TEPP boundary +returns a stable product-level unavailable message. Route handlers catch both +known transport/parse failures and unexpected provider exceptions, while +retaining the original exception only as an in-process chained cause for +operator logging. Provider response parsers use generic validation errors and +never interpolate the raw response into an exception message. + +Missing or malformed evidence remains unavailable; it is never converted into +a fabricated negative result. Existing input-validation errors outside a +provider boundary retain their client-actionable 422 detail. + +## Consequences + +- API clients receive a safe retry/configuration action rather than provider + internals. +- Server-side debugging keeps exception chaining without exposing it to buyers. +- Regression tests exercise unexpected exceptions, not only known transport + subclasses, and assert that provider secrets do not appear in responses. + +## References — APA 7th + +National Institute of Standards and Technology. (2020). *Security and privacy +controls for information systems and organizations* (NIST Special Publication +800-53 Rev. 5). https://doi.org/10.6028/NIST.SP.800-53r5 + +OWASP Foundation. (2025). *Improper error handling*. OWASP Application +Security Verification Standard. https://owasp.org/www-project-application-security-verification-standard/ + +MITRE. (2026). *CWE-209: Generation of error message containing sensitive +information*. https://cwe.mitre.org/data/definitions/209.html diff --git a/docs/adr/0103-valkey-account-operation-events.md b/docs/adr/0125-valkey-account-operation-events.md similarity index 96% rename from docs/adr/0103-valkey-account-operation-events.md rename to docs/adr/0125-valkey-account-operation-events.md index 4b0cc3d7a..b9aabce41 100644 --- a/docs/adr/0103-valkey-account-operation-events.md +++ b/docs/adr/0125-valkey-account-operation-events.md @@ -1,4 +1,4 @@ -# ADR 0103: Register account operation events in Valkey +# ADR 0125: Register account operation events in Valkey - Status: Accepted - Date: 2026-08-20 diff --git a/docs/ontology/lineageweave-kg.ttl b/docs/ontology/lineageweave-kg.ttl index 8d568c6d5..203ef14b4 100644 --- a/docs/ontology/lineageweave-kg.ttl +++ b/docs/ontology/lineageweave-kg.ttl @@ -10,26 +10,28 @@ ################################################################# # LineageWeave Knowledge Graph Ontology # -# The formal OWL 2 / RDFS / SKOS / W3C ORG vocabulary for the +# 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 -# `post_summary_role.actor_type_code`. +# `post_summary_role.actor_type_code` (migrations/0012). # # `knowledge_graph_edge` (source_node_type_code, source_node_id) -- -# [edge_type_code] --> (target_node_type_code, target_node_id) has an -# RDF triple shape, while PostgreSQL remains the source of record. The -# ontology publishes meaning and interoperability constraints; the -# companion SHACL graph publishes the closed-world cardinalities that -# OWL/RDFS intentionally do not enforce. +# [edge_type_code] --> (target_node_type_code, target_node_id) is +# already an RDF triple in shape (Cyganiak, Wood, & Lanthaler, 2014); +# 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 +# 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. # -# Every custom controlled-vocabulary term carries a :lookupCode -# annotation naming the exact `common_lookup_value.lookup_code` stored -# by the relational schema. +# Every custom term carries a :lookupCode annotation naming the exact +# `common_lookup_value.lookup_code` it corresponds to -- that literal +# string, not the IRI fragment, is what the relational schema stores. ################################################################# - - a owl:Ontology ; + a owl:Ontology ; owl:versionIRI ; owl:versionInfo "1.0.0" ; owl:imports @@ -37,10 +39,9 @@ , ; rdfs:label "LineageWeave Knowledge Graph Ontology" ; - rdfs:comment "Formal OWL 2 / RDFS / SKOS / W3C ORG vocabulary for LineageWeave navigation nodes, relations, organization containment, classification levels, and role-actor types." . + 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." . -:lookupCode - a owl:AnnotationProperty ; +:lookupCode a owl:AnnotationProperty ; rdfs:label "lookup code" ; rdfs:comment "The exact common_lookup_value.lookup_code string this ontology term corresponds to." . @@ -48,58 +49,50 @@ # Classes -- node_type ################################################################# -:Post - a owl:Class ; +:Post a owl:Class ; rdfs:label "Post" ; rdfs:comment "A source_post row: one VOC/VOM/VOP/VOCC/VOCO/VOS record." ; :lookupCode "node_post" . -:Person - a owl:Class ; +:Person a owl:Class ; rdfs:label "Person" ; rdfs:comment "A cataloged_person row: a Keyman mentioned in one or more posts." ; :lookupCode "node_person" . -:OurSidePerson - a owl:Class ; +:OurSidePerson a owl:Class ; rdfs:subClassOf :Person ; rdfs:label "Our-side person" ; :lookupCode "our_side" . -:CounterpartyPerson - a owl:Class ; +:CounterpartyPerson a owl:Class ; rdfs:subClassOf :Person ; rdfs:label "Counterparty person" ; :lookupCode "counterparty" . -:CorporateEntity - a owl:Class ; +:CorporateEntity a owl:Class ; rdfs:subClassOf org:Organization ; rdfs:label "Corporate entity" ; rdfs:comment "A real corporate_entity row, modeled as a W3C ORG organization. Its classification level is a separate SKOS concept reached through hasEntityLevel." ; :lookupCode "node_corporate_entity" . -:Team - a owl:Class ; +:Team a owl:Class ; rdfs:subClassOf org:OrganizationalUnit ; rdfs:label "Team" ; - rdfs:comment "A cataloged_team row: a named organizational unit with a stable team_id, distinct from the per-summary RoleActorTeam classification." ; + rdfs:comment "A cataloged_team row: a named company sub-unit (ADR 0009) with a stable team_id, distinct from :RoleActorTeam (ADR 0007's per-row actor_type_code classification) the same way :Person is distinct from :RoleActorPerson." ; :lookupCode "node_team" . ################################################################# # W3C ORG -- real organization containment and unit membership ################################################################# -:subOrganizationOf - a owl:ObjectProperty ; +:subOrganizationOf a owl:ObjectProperty ; rdfs:subPropertyOf org:subOrganizationOf ; rdfs:domain :CorporateEntity ; rdfs:range :CorporateEntity ; rdfs:label "sub-organization of" ; rdfs:comment "The semantic projection of corporate_entity.parent_entity_id. This is real organizational containment, not SKOS concept hierarchy." . -:hasSubOrganization - a owl:ObjectProperty ; +:hasSubOrganization a owl:ObjectProperty ; rdfs:subPropertyOf org:hasSubOrganization ; owl:inverseOf :subOrganizationOf ; rdfs:domain :CorporateEntity ; @@ -110,149 +103,132 @@ # Object properties -- edge_type (knowledge_graph_edge.edge_type_code) ################################################################# -:mentionedIn - a owl:ObjectProperty ; +:mentionedIn a owl:ObjectProperty ; rdfs:domain :Person ; rdfs:range :Post ; rdfs:label "mentioned in" ; - rdfs:comment "A person is named by a post; this is the canonical direction stored by knowledge_graph_edge." ; + rdfs:comment "A person is named by a post (post_person_mention); this is the canonical direction stored by knowledge_graph_edge." ; :lookupCode "edge_mention" . -:mentions - a owl:ObjectProperty ; +# Keep the natural-language inverse available to RDF consumers without +# assigning the relational lookup code to two different properties. +:mentions a owl:ObjectProperty ; rdfs:domain :Post ; rdfs:range :Person ; rdfs:label "mentions" ; owl:inverseOf :mentionedIn . -:affiliatedWith - a owl:ObjectProperty ; +:affiliatedWith a owl:ObjectProperty ; rdfs:domain :Person ; rdfs:range :CorporateEntity ; rdfs:label "affiliated with" ; - rdfs:comment "A person's many-to-many organizational affiliation." ; + rdfs:comment "A person's N:N organizational affiliation (person_affiliation)." ; :lookupCode "edge_affiliation" . -:coMentionedWith - a owl:ObjectProperty, owl:SymmetricProperty ; +:coMentionedWith a owl:ObjectProperty, owl:SymmetricProperty ; rdfs:domain :Person ; rdfs:range :Person ; rdfs:label "co-mentioned with" ; - rdfs:comment "Two people named in the same post; symmetric by construction." ; + rdfs:comment "Two people named in the same post -- symmetric by construction." ; :lookupCode "edge_co_mention" . ################################################################# -# Object properties -- cross-post actor identity +# Object properties -- ADR 0009 cross-post identity resolution edges. +# Kept distinct from :mentionedIn/:affiliatedWith (not reused with a +# broadened domain/range) so an edge_type_code alone always tells you +# which node types it connects -- stating rdfs:domain for the same +# property twice (once :Person, once :Team) would make RDFS entail +# every :mentionedIn subject is BOTH a :Person and a :Team, which is false. ################################################################# -:mentionsTeam - a owl:ObjectProperty ; +:mentionsTeam a owl:ObjectProperty ; rdfs:domain :Team ; rdfs:range :Post ; rdfs:label "mentioned in post" ; - rdfs:comment "A cataloged team is named by a post." ; + rdfs:comment "A cataloged team is named by a post (post_team_mention)." ; :lookupCode "edge_mention_team" . -:teamAffiliatedWith - a owl:ObjectProperty ; +:teamAffiliatedWith a owl:ObjectProperty ; rdfs:subPropertyOf org:unitOf ; rdfs:domain :Team ; rdfs:range :CorporateEntity ; - rdfs:label "team unit of organization" ; - rdfs:comment "The organization that owns a cataloged team, specializing W3C ORG unitOf." ; + rdfs:label "team affiliated with" ; + rdfs:comment "The company a cataloged team belongs to (cataloged_team.affiliated_corporate_entity_id)." ; :lookupCode "edge_team_affiliation" . -:mentionsOrganization - a owl:ObjectProperty ; +:mentionsOrganization a owl:ObjectProperty ; rdfs:domain :CorporateEntity ; rdfs:range :Post ; rdfs:label "mentioned in post" ; - rdfs:comment "A resolved organization is named by a post." ; + rdfs:comment "A resolved organization is named by a post (post_organization_mention)." ; :lookupCode "edge_mention_organization" . ################################################################# # Object properties -- entity_relationship_type +# (post_counterparty_entity.relationship_type_code) ################################################################# -:hasVocRelationship - a owl:ObjectProperty ; - rdfs:domain :Post ; - rdfs:range :CorporateEntity ; +:hasVocRelationship a owl:ObjectProperty ; + rdfs:domain :Post ; rdfs:range :CorporateEntity ; rdfs:label "has Voice-of-Customer relationship" ; :lookupCode "rel_voc" . -:hasVomRelationship - a owl:ObjectProperty ; - rdfs:domain :Post ; - rdfs:range :CorporateEntity ; +:hasVomRelationship a owl:ObjectProperty ; + rdfs:domain :Post ; rdfs:range :CorporateEntity ; rdfs:label "has Voice-of-Market relationship" ; :lookupCode "rel_vom" . -:hasVopRelationship - a owl:ObjectProperty ; - rdfs:domain :Post ; - rdfs:range :CorporateEntity ; +:hasVopRelationship a owl:ObjectProperty ; + rdfs:domain :Post ; rdfs:range :CorporateEntity ; rdfs:label "has Voice-of-Partner relationship" ; :lookupCode "rel_vop" . -:hasVoccRelationship - a owl:ObjectProperty ; - rdfs:domain :Post ; - rdfs:range :CorporateEntity ; +:hasVoccRelationship a owl:ObjectProperty ; + rdfs:domain :Post ; rdfs:range :CorporateEntity ; rdfs:label "has Voice-of-Customer's-Customer relationship" ; :lookupCode "rel_vocc" . -:hasVocoRelationship - a owl:ObjectProperty ; - rdfs:domain :Post ; - rdfs:range :CorporateEntity ; +:hasVocoRelationship a owl:ObjectProperty ; + rdfs:domain :Post ; rdfs:range :CorporateEntity ; rdfs:label "has Voice-of-Competitor relationship" ; :lookupCode "rel_voco" . -:hasVosRelationship - a owl:ObjectProperty ; - rdfs:domain :Post ; - rdfs:range :CorporateEntity ; +:hasVosRelationship a owl:ObjectProperty ; + rdfs:domain :Post ; rdfs:range :CorporateEntity ; rdfs:label "has Voice-of-Supplier relationship" ; :lookupCode "rel_vos" . ################################################################# -# SKOS -- corporate-entity level classification +# SKOS -- corporate_entity_level (Group -> Company -> Plant) ################################################################# -:CorporateEntityLevel - a owl:Class ; +:CorporateEntityLevel a owl:Class ; rdfs:subClassOf skos:Concept ; rdfs:label "Corporate entity level" ; rdfs:comment "A classification concept such as Group, Company, or Plant. It is not the real organization instance." . -:hasEntityLevel - a owl:ObjectProperty ; +:hasEntityLevel a owl:ObjectProperty ; rdfs:domain :CorporateEntity ; rdfs:range :CorporateEntityLevel ; rdfs:label "has corporate entity level" ; rdfs:comment "Projects corporate_entity.entity_level_code to the corresponding controlled SKOS concept." . -:corporateEntityLevelScheme - a skos:ConceptScheme ; +:corporateEntityLevelScheme a skos:ConceptScheme ; rdfs:label "Corporate entity level scheme" ; - rdfs:comment "The Group -> Company -> Plant classification hierarchy, ordered broadest first." . + rdfs:comment "The Acme Group -> Acme Electronics Korea -> Acme Electronics Gwangju Plant kind of level, ordered broadest first." . -:GroupLevel - a :CorporateEntityLevel, skos:Concept ; +:GroupLevel a :CorporateEntityLevel, skos:Concept ; skos:inScheme :corporateEntityLevelScheme ; skos:prefLabel "Group"@en ; :lookupCode "group" . -:CompanyLevel - a :CorporateEntityLevel, skos:Concept ; +:CompanyLevel a :CorporateEntityLevel, skos:Concept ; skos:inScheme :corporateEntityLevelScheme ; skos:broader :GroupLevel ; skos:prefLabel "Company"@en ; :lookupCode "company" . -:PlantLevel - a :CorporateEntityLevel, skos:Concept ; +:PlantLevel a :CorporateEntityLevel, skos:Concept ; skos:inScheme :corporateEntityLevelScheme ; skos:broader :CompanyLevel ; skos:prefLabel "Plant"@en ; @@ -263,65 +239,79 @@ ################################################################# # Classes -- prov_agent_type (post_summary_role.actor_type_code) +# +# A post's R&R (roles & responsibilities) actor is not always a person +# -- business correspondence routinely names an organization acting +# in its own name ("당사" [our company], "Demo Corp"). Grounded +# directly in W3C PROV-O (Lebo, Sahoo, & McGuinness, +# 2013): prov:Agent is the general acting-party class, with prov:Person +# and prov:Organization its two recognized subclasses. These are +# distinct from :Person / :OurSidePerson / :CounterpartyPerson above: +# node_type's :Person is a cataloged_person row with a stable person_id +# a Keyman panel links to; an R&R actor is a free-text name with no +# cataloged identity of its own (it may not even resolve to a Keyman). +# +# A third, meso-level case real data surfaced: a named sub-unit of a +# company ("설계팀" [design team]) is neither prov:Person nor the +# prov:Organization itself -- it is the company's own internal +# structure. PROV-O has no such class; the W3C Organization Ontology +# (Reynolds, 2014) does: org:OrganizationalUnit, "used to represent +# division of a particular organization into sub-organizational units," +# linked to its parent via org:unitOf. See docs/adr/0007-team-actor-type.md. ################################################################# -:RoleActorPerson - a owl:Class ; +:RoleActorPerson a owl:Class ; rdfs:subClassOf prov:Person ; rdfs:label "Role actor (person)" ; rdfs:comment "An R&R actor that is a named individual, per prov:Person." ; :lookupCode "prov_person" . -:RoleActorOrganization - a owl:Class ; +:RoleActorOrganization a owl:Class ; rdfs:subClassOf prov:Organization ; rdfs:label "Role actor (organization)" ; rdfs:comment "An R&R actor that is an organization acting in its own name, per prov:Organization." ; :lookupCode "prov_organization" . -:RoleActorTeam - a owl:Class ; +:RoleActorTeam a owl:Class ; rdfs:subClassOf org:OrganizationalUnit ; rdfs:label "Role actor (team)" ; - rdfs:comment "An R&R actor that is a named sub-unit of a company, per org:OrganizationalUnit." ; + rdfs:comment "An R&R actor that is a named sub-unit of a company (e.g. 설계팀), per org:OrganizationalUnit -- not the company itself." ; :lookupCode "prov_team" . ################################################################# -# Organization-name resolution semantics +# organization_name_resolution (raw/canonical organization-name pairs) # -# organization_name_resolution.raw_organization_name corresponds to -# skos:altLabel and resolved_organization_name to skos:prefLabel. These -# columns are not common_lookup_value rows, so no duplicate lookupCode -# term is declared here. +# ADR 0008: an abbreviated/slang organization mention (e.g. "AGP") +# is resolved to its full canonical name ("Aurora Grid Power") and +# cross-verified via external search before being trusted. This is not +# a new KG node/edge type -- no new :lookupCode term is declared here, +# since organization_name_resolution's columns are not a +# common_lookup_value category (there is nothing for +# tests/test_ontology.py's round-trip check to enforce). Documented +# here for the Ontology/Semantic-Layer grounding itself: +# `organization_name_resolution.raw_organization_name` corresponds to +# SKOS `skos:altLabel` (an alternative label -- an abbreviation is +# exactly this) and `resolved_organization_name` to `skos:prefLabel` +# (the single preferred/canonical label), per Miles & Bechhofer (2009). ################################################################# - -################################################################# -# Semantic project extraction -################################################################# - -:Project - a owl:Class ; +# Semantic project extraction (ADR 0036). These resources are distinct from +# imported grouping fields: a post may mention a project without carrying a +# project field, and the mention keeps evidence/confidence for review. +:Project a owl:Class ; rdfs:label "Project"@en ; rdfs:comment "A business project referred to by a source post."@en . -:ProjectMention - a owl:Class ; +:ProjectMention a owl:Class ; rdfs:label "Project mention"@en ; rdfs:comment "An evidence-backed semantic assertion that a post refers to a project."@en . -:mentionsProject - a owl:ObjectProperty ; +:mentionsProject a owl:ObjectProperty ; rdfs:domain :Post ; - rdfs:range :Project ; - rdfs:label "mentions project"@en . + rdfs:range :Project . -:projectEvidence - a owl:DatatypeProperty ; +:projectEvidence a owl:DatatypeProperty ; rdfs:domain :ProjectMention ; - rdfs:range xsd:string ; - rdfs:label "project evidence"@en . + rdfs:range xsd:string . -:semanticConfidence - a owl:DatatypeProperty ; - rdfs:range xsd:decimal ; - rdfs:label "semantic confidence"@en . +:semanticConfidence a owl:DatatypeProperty ; + rdfs:range xsd:decimal . diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index f6640d5c7..e94ffb442 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,17 +1,27 @@ -# Product, technical, and gap baseline - -**Snapshot:** 2026-08-21 (Asia/Seoul) -**Protected-main baseline:** `origin/main`; this document does not claim the active PR is shipped. -**Audited PR code head:** #258 customer-hierarchy commit `21074cf80cbfab3001bf18b6e1a618f75f4bed24`; this active branch is not protected-main truth. -**Active PR update:** Customer Master now has an ORG-grounded, cycle-safe hierarchy projection with -explicit WAI-ARIA ownership; final-head hosted Checks and independent approval remain required. -**Purpose:** connect the normative ADRs and research evidence to product -requirements, technical contracts, implementation evidence, and active PRs. -An active PR is proposed work, not shipped behavior. - -## PRD - -### Problem and outcome +# 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). Buyers need to turn scattered, timestamped records into reviewable branching histories without confusing a plausible relation with a proven fact. The @@ -249,3 +259,5 @@ ADRs remain normative. This document is the product/technical traceability projection: update the affected FR/NFR row and Gap closure evidence when an ADR or PR changes product behavior. Never turn a PR title, green unit test, or old runtime note into a shipped/live claim. + +*This document is continuously updated by the hourly automated agent loop.* diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index ce726f165..28c59bd48 100644 --- a/docs/storybook-inventory.md +++ b/docs/storybook-inventory.md @@ -8,7 +8,6 @@ buyer-facing control you can click before changing product CSS. | `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` | -| `Customers/CustomerMasterTree` | Traverse Group → Company → Plant, open source-backed posts, and review unresolved hierarchy relations. | `--space-control-gap`, `--size-control-min`, `--radius-control`, `CustomerMasterTree` | | `Chrome/PopupCloseButton` | Close the evidence panel or post popup. | `--space-close-inset`, `--font-size-close`, `PopupCloseButton` | Repeated web objects must use `frontend/src/styles/tokens.css` and a module diff --git a/fix_prompts.py b/fix_prompts.py new file mode 100644 index 000000000..551b63ec0 --- /dev/null +++ b/fix_prompts.py @@ -0,0 +1,46 @@ +import re +import os + +def update_file(path, replacements): + with open(path, "r") as f: + content = f.read() + for old, new in replacements: + content = content.replace(old, new) + with open(path, "w") as f: + f.write(content) + +update_file("lineageweave/post_summary.py", [ + ( + '"major_event_actions": [{"event_type": "string", "actor_name": "string", "actor_company_name": "string"}]', + '"major_event_actions": [{"event_type": "string", "actor_name": "string", "actor_company_name": "string"}],\n "projects": ["project1", "project2"],\n "five_w1h": {"who": "...", "what": "...", "when": "...", "where": "...", "why": "...", "how": "..."}' + ), + ( + "For roles_and_responsibilities, list the known tasks", + "For roles_and_responsibilities, list the known tasks (explicitly specify who requested, who processes, and who approved)" + ) +]) + +update_file("lineageweave/keyman_extraction.py", [ + ( + "Do not invent roles or affiliations.", + "Do not invent roles or affiliations. Ensure you extract unnamed specific roles (like 'PMs') and organizational teams (like '설계팀') as keymen if individuals are not named." + ) +]) + +update_file("lineageweave/organization_name_resolution.py", [ + ( + "Only use information present in the text.", + "Use information present in the text, but you may use general knowledge to expand well-known abbreviations (e.g. '한전' -> '한국전력') as they will be verified." + ) +]) + +update_file("lineageweave/image_content.py", [ + ( + 'class ImageDescription(BaseModel):', + 'class ImageDescription(BaseModel):\n ontology_mapping: dict = Field(default_factory=dict)' + ), + ( + '"extracted_text": "any text visible in the image"', + '"extracted_text": "any text visible in the image",\n "ontology_mapping": {"field": "value"}' + ) +]) diff --git a/frontend/index.html b/frontend/index.html index 884ca64d0..267c7e3ec 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,6 +4,12 @@ + + + LineageWeave diff --git a/frontend/src/App.css b/frontend/src/App.css index 67132503b..f595b7857 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,28 +1,231 @@ -#root { - max-width: 960px; - margin: 0 auto; +/* App-level Shell Layout */ +.app-shell { + display: flex; + flex-direction: column; + min-height: 100vh; + width: 100%; +} + +.app-shell > main { + flex: 1; padding: 1.5rem; - text-align: left; } -.centered { +/* Login Screen (§3.2 로그인 페이지) */ +.login-screen { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 4rem 1.5rem; + background: var(--bg); +} + +.login-card { + width: 100%; + max-width: 420px; + padding: 2.5rem 2rem; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface); + box-shadow: var(--shadow); + display: flex; + flex-direction: column; + gap: 1.5rem; text-align: center; - padding-top: 4rem; } +.login-header h1 { + font-size: 2rem; + color: var(--color-primary); + margin: 0 0 0.4rem; +} + +.login-subtitle { + font-size: 0.9rem; + color: var(--text-muted); + margin: 0; +} + +.login-controls { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.login-controls button { + width: 100%; + min-height: 2.75rem; +} + +.login-help { + color: var(--text-muted); + font-size: 0.8rem; +} + +/* App Header (§2.2.1 & §2.2.2) */ .app-header { + position: sticky; + top: 0; + z-index: var(--z-header); + background: var(--color-header-bg); + border-bottom: 1px solid var(--color-header-border); + padding: 0 1.5rem; + height: var(--header-height); display: flex; justify-content: space-between; align-items: center; - margin-bottom: 1.5rem; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04); } -.app-header > div { +.app-header-logo { display: flex; + align-items: center; gap: 0.75rem; +} + +.app-header-title { + font-size: 1.4rem; + font-weight: 700; + margin: 0; + color: var(--color-primary); +} + +.app-header-top-menu { + display: flex; + gap: 0.85rem; align-items: center; } +.app-user-profile { + font-size: 0.85rem; + font-weight: 600; + color: var(--color-text-heading); + background: var(--color-table-row-hover); + padding: 0.25rem 0.75rem; + border-radius: 999px; + border: 1px solid var(--border); +} + +/* Drawer Menu Trigger (Mobile) */ +.mobile-drawer-trigger { + display: none; + background: transparent; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: var(--color-text-heading); +} + +/* App Footer (§2.2.3 & §2.2.4) */ +.app-footer { + margin-top: auto; + padding: 1.25rem 1.5rem; + min-height: var(--footer-min-height); + background: var(--color-footer-bg); + border-top: 1px solid var(--color-footer-border); + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.82rem; + color: var(--color-footer-text); +} + +.app-footer-title { + font-weight: 700; + letter-spacing: 0.04em; + color: var(--color-primary); +} + +.app-footer-copyright { + color: var(--color-footer-text); +} + +/* GNB Navigation (§2.3.1) */ +.buyer-gnb { + display: flex; + align-items: center; + height: var(--gnb-height); + background: var(--color-header-bg); + border-bottom: 1px solid var(--border); + padding: 0 1.5rem; + margin-bottom: 1.5rem; + gap: 1.5rem; + position: relative; + z-index: var(--z-gnb-pulldown); +} + +.buyer-gnb-item { + display: flex; + align-items: center; + height: 100%; + border: 0; + background: transparent; + color: var(--color-text); + font: inherit; + font-weight: 700; + cursor: pointer; + position: relative; + padding: 0 0.5rem; + transition: color 0.15s ease; +} + +.buyer-gnb-item:hover { + color: var(--color-text-heading); +} + +.buyer-gnb-item[aria-current="page"] { + color: var(--color-primary); +} + +.buyer-gnb-item[aria-current="page"]::after { + content: ""; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: var(--gnb-active-indicator-height); + 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); + color: var(--color-btn-primary-text); + border: 1px solid transparent; + border-radius: var(--radius-control); + padding: 0.5rem 1.15rem; + font-weight: 600; + cursor: pointer; + transition: background-color 0.15s ease-in-out; +} + +.btn-primary:hover { + 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:hover { + background: var(--color-btn-secondary-hover); +} + +/* Language Switcher */ .language-switcher { display: inline-flex; align-items: center; @@ -30,19 +233,25 @@ .language-switcher select { min-height: var(--size-control-min); - padding: 0.25rem 1.8rem 0.25rem 0.55rem; + padding: 0.35rem 1.8rem 0.35rem 0.65rem; border: 1px solid var(--border); border-radius: var(--radius-control); - background: var(--bg); + background: var(--surface); color: var(--text-h); font: inherit; - font-size: 0.8rem; + font-size: 0.82rem; + cursor: pointer; } .error { - color: #b91c1c; + color: var(--color-status-alert); } +.status-alert { + color: var(--color-status-alert); +} + +/* Post List */ .post-list { list-style: none; padding: 0; @@ -56,8 +265,8 @@ align-items: center; padding: 0.75rem 1rem; margin-bottom: 0.5rem; - border: 1px solid #3333; - border-radius: 8px; + border: 1px solid var(--border); + border-radius: var(--radius-control); background: none; cursor: pointer; text-align: left; @@ -243,25 +452,29 @@ text-transform: uppercase; } +/* Popup / Modals (§3.6.1 모달 레이어 투명도 50%) */ .popup-backdrop { position: fixed; inset: 0; - background: rgba(0, 0, 0, 0.5); + background: rgba(0, 0, 0, 0.5); /* 50% opacity per guide */ display: flex; align-items: center; justify-content: center; + z-index: var(--z-modal-backdrop); } .popup-panel { position: relative; - background: canvas; - color: canvastext; + background: var(--surface); + color: var(--text); max-width: 720px; width: 90%; max-height: 85vh; overflow-y: auto; padding: 2rem; border-radius: 12px; + z-index: var(--z-modal); + box-shadow: var(--shadow); } .popup-close { @@ -272,36 +485,13 @@ border: none; font-size: var(--font-size-close); cursor: pointer; + color: var(--text-h); } -:root { - --lw-opacity-meta: 0.7; - --lw-font-size-meta: 0.85rem; - --lw-color-warning: #b45309; -} - +/* Posts & Evidence */ .post-meta { - opacity: var(--lw-opacity-meta); - font-size: var(--lw-font-size-meta); -} - -.post-actions { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; - align-items: center; - margin: 0.75rem 0 1rem; -} - -.post-actions a, -.post-actions button { - font: inherit; - padding: 0.35rem 0.6rem; -} - -.post-action-status { - margin: -0.5rem 0 1rem; - color: var(--lw-color-warning); + opacity: 0.7; + font-size: 0.85rem; } .visually-hidden { @@ -327,23 +517,6 @@ white-space: pre-wrap; } -.post-body-footnote { - padding-inline-start: 1rem; - font-size: 0.9em; -} - -.post-body-table { - width: 100%; - border-collapse: collapse; - overflow-wrap: anywhere; -} - -.post-body-table td { - padding: 0.35rem 0.5rem; - border: 1px solid var(--post-image-border); - vertical-align: top; -} - .post-embedded-image { margin: 0; padding: var(--post-image-padding); @@ -358,41 +531,6 @@ height: auto; } -@media print { - body * { - visibility: hidden; - } - - .popup-backdrop, - .popup-panel, - .popup-panel * { - visibility: visible; - } - - .popup-backdrop { - position: absolute; - inset: 0; - display: block; - background: none; - } - - .popup-panel { - position: static; - width: auto; - max-width: none; - max-height: none; - overflow: visible; - padding: 0; - border-radius: 0; - } - - .popup-close, - .post-actions, - .post-action-status { - display: none !important; - } -} - .post-embedded-image figcaption { margin-top: 0.4rem; font-size: 0.85rem; @@ -402,7 +540,7 @@ .popup-placeholder { margin-top: 1.5rem; padding: 1rem; - border: 1px dashed #3336; + border: 1px dashed var(--border); border-radius: 8px; font-size: 0.85rem; opacity: 0.7; @@ -411,9 +549,9 @@ .popup-live-body-warning { margin: 0.75rem 0 1rem; padding: 0.65rem 0.75rem; - border-left: 3px solid var(--lw-color-warning); - background: color-mix(in srgb, canvas 88%, var(--lw-color-warning) 12%); - font-size: var(--lw-font-size-meta); + border-left: 3px solid var(--color-accent-orange); + background: var(--color-accent-background); + font-size: 0.85rem; } .cutoff-known-body { @@ -431,7 +569,7 @@ .popup-section { margin-top: 1.5rem; padding-top: 1rem; - border-top: 1px solid #3332; + border-top: 1px solid var(--border); } .popup-section h3 { @@ -480,335 +618,6 @@ font-size: 0.85rem; } -.related-posts-section { - margin-top: 1.25rem; - padding: 1rem; - border: 1px solid var(--accent-border); - border-radius: var(--radius-panel); - background: var(--accent-bg); -} - -.related-posts-context { - margin: 0 0 0.9rem; - padding: 0.75rem; - border: 1px solid var(--accent-border); - border-radius: var(--radius-control); - background: color-mix(in srgb, var(--accent-bg) 65%, var(--bg)); -} - -.related-posts-context h5 { - margin: 0 0 0.6rem; - color: var(--text-h); - font-size: 0.9rem; -} - -.related-posts-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 1rem; - margin-bottom: 0.75rem; -} - -.section-eyebrow { - margin: 0 0 0.2rem; - color: var(--accent); - font-size: 0.7rem; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.related-post-count { - flex: 0 0 auto; - padding: 0.2rem 0.55rem; - border: 1px solid var(--accent-border); - border-radius: 999px; - color: var(--text-h); - font-size: 0.75rem; - font-weight: 700; -} - -.related-post-list { - display: grid; - gap: 0.6rem; - list-style: none; - margin: 0; - padding: 0; -} - -.related-post-card { - display: grid; - grid-template-columns: auto minmax(0, 1fr) auto; - align-items: center; - width: 100%; - gap: 0.7rem; - padding: 0.75rem 0.85rem; - border: 1px solid var(--border); - border-radius: var(--radius-control); - background: var(--bg); - color: var(--text-h); - cursor: pointer; - font: inherit; - text-align: left; - transition: border-color 140ms ease, transform 140ms ease, box-shadow 140ms ease; -} - -.related-post-card:hover, -.related-post-card:focus-visible { - border-color: var(--accent); - box-shadow: var(--shadow); - outline: none; - transform: translateY(-1px); -} - -.related-post-card-static { - cursor: default; -} - -.related-post-kind, -.related-post-cta { - color: var(--text); - font-size: 0.75rem; -} - -.related-post-kind { - white-space: nowrap; -} - -.related-post-content { - display: grid; - min-width: 0; - gap: 0.25rem; -} - -.related-post-cta { - color: var(--accent); - font-weight: 700; - white-space: nowrap; -} - -@media (max-width: 640px) { - .app-header, - .app-header > div { - align-items: flex-start; - } - - .app-header, - .app-header > div { - flex-wrap: wrap; - } - - .related-post-card { - grid-template-columns: 1fr auto; - } - - .related-post-kind { - grid-column: 1 / -1; - } -} - -.buyer-gnb { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)) auto; - gap: 0.45rem; - margin: 0 0 1.5rem; - padding: 0.35rem; - border: 1px solid var(--border); - border-radius: 14px; - background: var(--surface-muted); -} - -.buyer-gnb-tools { - display: flex; - align-items: center; - justify-content: center; - padding: 0 0.35rem; -} - -.advanced-review-tools { - margin: 0 0 1.5rem; - border: 1px solid var(--border); - border-radius: 12px; - background: var(--surface-muted); -} - -.advanced-review-tools > summary { - padding: 0.85rem 1rem; - color: var(--text-muted); - font-weight: 700; - cursor: pointer; -} - -.advanced-review-tools[open] > summary { - border-bottom: 1px solid var(--border); -} - -.advanced-review-tools > .popup-section { - margin: 1rem; -} - -.operator-action-tools { - margin-left: auto; - color: var(--text-muted); -} - -.operator-action-tools > summary { - cursor: pointer; - font-size: 0.82rem; - font-weight: 700; -} - -.operator-action-tools > button { - margin-top: 0.45rem; -} - -.buyer-gnb-item { - min-height: 2.75rem; - border: 0; - border-radius: 10px; - background: transparent; - color: var(--text-muted); - font: inherit; - font-weight: 700; - cursor: pointer; -} - -.buyer-gnb-item:hover, -.buyer-gnb-item:focus-visible, -.buyer-gnb-item[aria-current="page"] { - background: var(--surface); - color: var(--text); - box-shadow: 0 2px 8px rgb(15 23 42 / 8%); -} - -.buyer-destination { - padding: 1.5rem; - border: 1px solid var(--border); - border-radius: 16px; - background: var(--surface); -} - -.buyer-destination h2 { - margin: 0.2rem 0 0.5rem; -} - -.buyer-destination-intro { - max-width: 50rem; - color: var(--text-muted); -} - -.customer-master-list { - display: grid; - gap: 0.75rem; - margin: 1.25rem 0 0; - padding: 0; - list-style: none; -} - -.customer-master-list li { - display: flex; - flex-direction: column; - gap: 0.25rem; - padding: 1rem; - border: 1px solid var(--border); - border-radius: 12px; -} - -.customer-entity-button { - display: flex; - flex-direction: column; - gap: 0.25rem; - align-items: flex-start; - width: 100%; - padding: 0; - border: 0; - background: transparent; - color: inherit; - font: inherit; - text-align: left; - cursor: pointer; -} - -.customer-entity-button:focus-visible { - outline: 3px solid var(--accent); - outline-offset: 4px; -} - -.customer-related-posts { - margin-top: 0.75rem; - padding-top: 0.75rem; - border-top: 1px solid var(--border); -} - -.customer-related-posts ul { - display: grid; - gap: 0.5rem; - margin: 0; - padding: 0; - list-style: none; -} - -.customer-related-posts .related-post-card { - width: 100%; - border-radius: 8px; -} - -.customer-keymen { - margin-top: 1.5rem; -} - -.customer-master-list span { - color: var(--text-muted); - font-size: 0.85rem; -} - -.ask-agent-source { - display: grid; - gap: 0.45rem; - max-width: 42rem; - margin: 1.25rem 0; - font-weight: 700; -} - -.ask-agent-source select { - min-height: 2.75rem; - padding: 0.5rem 0.75rem; - border: 1px solid var(--border); - border-radius: 8px; - background: var(--surface); - color: var(--text); - font: inherit; -} - -@media (max-width: 640px) { - .buyer-gnb { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .buyer-gnb-tools { - grid-column: 1 / -1; - justify-content: flex-end; - } - - .buyer-destination { - padding: 1rem; - } - - .board-header { - align-items: flex-start; - flex-direction: column; - } - - .board-controls { - grid-template-columns: 1fr; - } - - .board-result-count { - white-space: normal; - } -} - .lineage-home { border-top: none; padding-top: 0; @@ -829,7 +638,7 @@ flex-direction: column; align-items: flex-start; gap: var(--space-control-gap); - font-size: var(--lw-font-size-meta); + font-size: 0.85rem; } .lineage-entity-picker select { @@ -837,8 +646,8 @@ min-width: 12rem; border: 1px solid var(--color-border); border-radius: var(--radius-control); - background: var(--color-background); - color: var(--color-text-heading); + background: var(--surface); + color: var(--text-h); } .lineage-dag-group { @@ -852,13 +661,13 @@ } .lineage-dag svg { - border: 1px solid #3333; + border: 1px solid var(--border); border-radius: 8px; - background: canvas; + background: var(--surface); } .lineage-dag-edge { - stroke: color-mix(in srgb, canvastext 35%, transparent); + stroke: var(--border); stroke-width: 1.5; fill: none; } @@ -868,14 +677,14 @@ } .lineage-dag-node circle { - fill: color-mix(in srgb, canvastext 8%, canvas); - stroke: color-mix(in srgb, canvastext 40%, transparent); + fill: var(--surface-muted); + stroke: var(--border); stroke-width: 1.5; } .lineage-dag-branch circle { - fill: color-mix(in srgb, orange 25%, canvas); - stroke: orange; + fill: var(--badge-actor-organization-bg); + stroke: var(--color-accent-orange); } .lineage-dag-root circle { @@ -884,7 +693,7 @@ .lineage-dag-node text { font-size: 11px; - fill: canvastext; + fill: var(--text-h); } .lineage-dag-node:focus { @@ -898,7 +707,7 @@ .lineage-dag-node[aria-current="true"] circle { stroke-width: 3; - stroke: canvastext; + stroke: var(--text-h); } .keyman-list { @@ -1018,6 +827,12 @@ font-size: 0.8rem; } +.customer-group-abbreviations { + list-style: none; + padding-left: 1.25rem; + margin: 0.15rem 0 0; +} + .voc-excerpt-list { list-style: none; padding: 0; @@ -1036,8 +851,8 @@ .voc-counterparty-excerpt { margin: 0.4rem 0; padding: 0.5rem 0.75rem; - border-left: 3px solid #c45c26; - background: #3331; + border-left: 3px solid var(--color-accent-orange); + background: var(--surface-muted); } .related-keymen { @@ -1072,7 +887,9 @@ flex: 1; padding: 0.5rem; border-radius: 6px; - border: 1px solid var(--color-border-subtle); + border: 1px solid var(--border); + background: var(--surface); + color: var(--text); } .chat-section { @@ -1089,7 +906,9 @@ flex: 1; padding: 0.5rem; border-radius: 6px; - border: 1px solid var(--color-border-subtle); + border: 1px solid var(--border); + background: var(--surface); + color: var(--text); } .chat-suggestions { @@ -1100,19 +919,20 @@ } .chat-suggestion-chip { - border: 1px solid var(--color-border-subtle); + border: 1px solid var(--border); border-radius: 999px; padding: 0.15rem 0.7rem; background: none; cursor: pointer; font-size: 0.85rem; + color: var(--text); } .chat-answer { margin-top: 0.75rem; padding: 0.75rem; border-radius: 8px; - background: #3331; + background: var(--surface-muted); } .chat-question { @@ -1134,6 +954,7 @@ background: none; cursor: pointer; font-family: var(--font-family-chip); + color: var(--text); } .evidence-panel { @@ -1142,12 +963,12 @@ right: 0; bottom: 0; width: min(400px, 90vw); - background: canvas; - color: canvastext; + background: var(--surface); + color: var(--text); box-shadow: -4px 0 16px rgba(0, 0, 0, 0.25); padding: 2rem 1.5rem; overflow-y: auto; - z-index: 10; + z-index: var(--z-evidence-panel); animation: slide-in-from-right 0.2s ease-out; } @@ -1159,3 +980,36 @@ transform: translateX(0); } } + +/* Responsive Overrides */ +@media (max-width: 1024px) { + /* Tablet Breakpoint (768px - 1024px) */ + .app-header { + padding: 0 1rem; + } + .buyer-gnb { + padding: 0 1rem; + } +} + +@media (max-width: 768px) { + /* Phone Breakpoint (<768px) */ + + .buyer-gnb { + display: none; /* Replaced by drawer on mobile */ + } + + .mobile-drawer-trigger { + display: block; + } + + .app-header { + padding: 0 1rem; + } + + .app-footer { + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + } +} diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index bc838b48d..fae84b865 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -121,6 +121,8 @@ describe("App, authenticated", () => { visibility_label?: string; created_at: string; }[]; + staleSummary?: boolean; + contentAfterSummary?: boolean; }): ReturnType & { releaseMe: () => void; releaseSecondAsk: () => void; @@ -151,6 +153,7 @@ describe("App, authenticated", () => { let createdPendingLineage: Record | null = null; let createdPendingTepp: Record | null = null; let resolvedHintCode: string | null = null; + let contentRequests = 0; let releaseMe = () => {}; const meReady = options?.deferMe @@ -188,6 +191,9 @@ describe("App, authenticated", () => { const url = String(input); const method = init?.method ?? "GET"; + if (url.endsWith("/api/settings")) { + return Promise.resolve(jsonResponse({ brandName: "LineageWeave" })); + } if (url.endsWith("/api/me/preferences") && method === "PATCH") { const body = JSON.parse(String(init?.body)); return Promise.resolve(jsonResponse({ preferred_locale: body.preferred_locale })); @@ -1181,7 +1187,28 @@ describe("App, authenticated", () => { ); } if (postOneUrl.pathname === "/api/posts/post-1/content") { - return Promise.resolve(jsonResponse({ images: [] })); + contentRequests += 1; + return Promise.resolve( + jsonResponse({ + status: "ready", + images: [], + units: + options?.contentAfterSummary && contentRequests > 1 + ? [ + { + unit_index: 0, + unit_kind_code: "plain_text", + unit_label: "p", + unit_text: "Freshly processed source paragraph.", + indent_level: 0, + indent_source_code: "explicit", + indent_confidence: 1, + indent_evidence: "HTML paragraph boundary", + }, + ] + : [], + }), + ); } if (url.endsWith("/api/posts/post-2")) { if (options?.evidenceUnavailable) { @@ -1225,6 +1252,9 @@ describe("App, authenticated", () => { jsonResponse({ post_id: "post-1", korean_summary: "이것은 요약입니다.", + ...(options?.staleSummary + ? { summary_status: "stale", summary_contract_version: 4 } + : {}), key_events: ["첫 번째 이벤트"], roles_and_responsibilities: [ { @@ -2544,6 +2574,37 @@ describe("App, authenticated", () => { expect(keyman.compareDocumentPosition(ask) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0); }); + it("labels a stale summary and retries the semantic refresh on request", async () => { + const fetchMock = stubBackend({ staleSummary: true }); + render(); + + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + await waitFor(() => + expect(screen.getByText("Last saved summary shown. Retry semantic refresh.")).toBeInTheDocument(), + ); + const summaryCallsBeforeRetry = fetchMock.mock.calls.filter(([input]) => + String(input).endsWith("/api/posts/post-1/summary"), + ).length; + + await userEvent.click(screen.getByRole("button", { name: "Retry summary refresh" })); + await waitFor(() => + expect( + fetchMock.mock.calls.filter(([input]) => String(input).endsWith("/api/posts/post-1/summary")) + .length, + ).toBeGreaterThan(summaryCallsBeforeRetry), + ); + expect(screen.getByRole("button", { name: "Retry summary refresh" })).toBeInTheDocument(); + }); + + it("refreshes newly processed source content after summary generation", async () => { + stubBackend({ contentAfterSummary: true }); + render(); + + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + + expect(await screen.findByText("Freshly processed source paragraph.")).toBeInTheDocument(); + }); + it("shows a seeded Ask exchange without an orchestrator round-trip", async () => { stubBackend(); render(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e6d321f64..d87dd9aa9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,3 +1,5 @@ +import { AdminPanel } from "./components/AdminPanel"; + import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { useAuth } from "react-oidc-context"; import { @@ -77,17 +79,18 @@ import { type RelatedNode, type RelatedNodeType, type VocEvidence, + fetchTenantConfig, } from "./api"; 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 { CustomerMasterTree, CustomerRelatedPostCard } from "./components/CustomerMasterTree"; import { LineageDag } from "./LineageDag"; import { PostBody } from "./PostBody"; import { decodeHtmlEntities } from "./postBodyDisplay"; import { FiveW1H } from "./components/FiveW1H"; +import { CustomerMasterTree, CustomerRelatedPostCard } from "./components/CustomerMasterTree"; import { subgraphForPost } from "./lineageLayout"; import { isSupportedLocale, @@ -103,7 +106,6 @@ import { analysisRunTargetClock, type AnalysisRunNavigationContext, } from "./analysisRunNavigation"; -import { rememberOidcReturnUrl, returnUrlFromLocation } from "./oidcReturnUrl"; import "./App.css"; const GLOBAL_ASK_SESSION_STORAGE_KEY = "lineageweave.globalAskSessionId"; @@ -1703,6 +1705,7 @@ function PostDetailPopup({ const [error, setError] = useState(null); const [summary, setSummary] = useState(null); const [summaryError, setSummaryError] = useState(null); + const [summaryRetry, setSummaryRetry] = useState(0); const [fiveW1H, setFiveW1H] = useState(null); const [keymen, setKeymen] = useState(null); const [sourceAuthorContext, setSourceAuthorContext] = useState(null); @@ -1714,6 +1717,7 @@ function PostDetailPopup({ const [focusPerson, setFocusPerson] = useState<{ personId: string; personName: string } | null>(null); const [focusEntity, setFocusEntity] = useState<{ entityId: string; entityName: string } | null>(null); const [focusTeam, setFocusTeam] = useState<{ teamId: string; teamName: string } | null>(null); + const contentReloadRef = useRef<() => void>(() => undefined); const detailRequestGeneration = useRef(0); @@ -1813,6 +1817,7 @@ function PostDetailPopup({ setImageContent([]); setStructureUnits([]); }); + contentReloadRef.current = reloadContent; void reloadContent(); fetchPostBookmark(accessToken, postId) .then((r) => { @@ -1890,9 +1895,33 @@ function PostDetailPopup({ disposed = true; if (contentPollTimer !== undefined) window.clearTimeout(contentPollTimer); if (isCurrent()) detailRequestGeneration.current = generation + 1; + if (contentReloadRef.current === reloadContent) { + contentReloadRef.current = () => undefined; + } }; }, [postId, accessToken, liveBodyWarning, knowledgeCutoff]); + useEffect(() => { + let disposed = false; + setSummary(null); + setSummaryError(null); + fetchPostSummary(accessToken, postId) + .then((value) => { + if (!disposed) { + setSummary(value); + contentReloadRef.current(); + } + }) + .catch((err) => { + if (disposed) return; + setSummary(null); + setSummaryError(summaryFetchError(err)); + }); + return () => { + disposed = true; + }; + }, [postId, accessToken, summaryRetry]); + const permanentLink = (() => { const url = new URL(window.location.href); url.searchParams.set("post", postId); @@ -2176,13 +2205,24 @@ function PostDetailPopup({

{t("Summary")}

{summary ? ( <> + {summary.summary_status === "stale" ? ( +

+ {t("Last saved summary shown. Retry semantic refresh.")} {" "} + +

+ ) : null}

{summary.korean_summary}

- {summary.key_events.length > 0 && ( + {(summary.key_event_details?.length ?? summary.key_events.length) > 0 && ( <>

{t("Key events")}

    - {summary.key_events.map((event, i) => ( -
  • {event}
  • + {(summary.key_event_details ?? summary.key_events.map((event) => ({ event_text: event, project_name: null }))).map((event, i) => ( +
  • + {event.project_name ? {event.project_name}: : null} + {event.event_text} +
  • ))}
@@ -2291,7 +2331,10 @@ function PostDetailPopup({
    {summary.major_event_actions.map((action, i) => (
  • - {action.action_text} + + {action.project_name ? `${action.project_name}: ` : ""} + {action.action_text} +
    {t("Requester")}: {action.requester_actor_name ?? t("Not stated in source")}
    @@ -4331,6 +4374,11 @@ function CustomerMasterPanel({ } } + const loadRelated = useCallback( + async (entityId: string) => (await fetchRelatedEntity(accessToken, entityId)).related, + [accessToken], + ); + return (

    {t("Authorized customer scope")}

    @@ -4349,9 +4397,7 @@ function CustomerMasterPanel({ {master && master.corporate_entities.length > 0 ? ( - fetchRelatedEntity(accessToken, entityId).then((response) => response.related) - } + loadRelated={loadRelated} onOpenPost={onOpenPost} /> ) : null} @@ -4631,6 +4677,7 @@ function AskAgentPanel({ export default function App({ showLabPanels = false }: { showLabPanels?: boolean } = {}) { useLocale(); + const [brandName, setBrandName] = useState("LineageWeave"); const auth = useAuth(); const [destination, setDestination] = useState("board"); const [postToOpen, setPostToOpen] = useState(() => { @@ -4647,6 +4694,14 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean const testOnlyLabPanels = import.meta.env.MODE === "test" && showLabPanels; const accessToken = auth.user?.access_token; + useEffect(() => { + if (accessToken) { + fetchTenantConfig(accessToken).then((config) => { + if (config.brandName) setBrandName(config.brandName); + }).catch(console.error); + } + }, [accessToken]); + useEffect(() => { if (!accessToken) return; const postId = new URLSearchParams(window.location.search).get("post"); @@ -4671,24 +4726,40 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean } if (auth.error) { - return

    Authentication error: {auth.error.message}

    ; + return

    {t(auth.error.message)}

    ; } if (!auth.isAuthenticated) { return ( -
    -

    LineageWeave

    - - +
    +
    +
    +
    +

    {brandName}

    +

    Marketing & Operational Lineage Intelligence

    +
    +
    + +
    +
    + Enterprise SSO Authentication +
    +
    +
    +
    + {brandName} +
    +
    +

    Copyright © {new Date().getFullYear()} by {brandName}. All rights reserved.

    +
    +
    +
    ); } @@ -4697,12 +4768,15 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean } return ( -
    +
    -

    LineageWeave

    -
    - {auth.user?.profile.preferred_username} +
    +

    {brandName}

    +
    +
    + {auth.user?.profile.preferred_username}
    +
    +
    + {brandName} +
    +
    +

    Copyright © {new Date().getFullYear()} by {brandName}. All rights reserved.

    +
    +
    + ); } diff --git a/frontend/src/PostBody.test.tsx b/frontend/src/PostBody.test.tsx index 63a4e3e81..1a9b00c4a 100644 --- a/frontend/src/PostBody.test.tsx +++ b/frontend/src/PostBody.test.tsx @@ -45,6 +45,19 @@ describe("PostBody", () => { expect(screen.queryByAltText(/character offset/i)).not.toBeInTheDocument(); }); + it("rejects script and active SVG image sources before browser rendering", () => { + const { rerender } = render('} />); + + expect(screen.queryByRole("img")).not.toBeInTheDocument(); + + rerender( + '} />, + ); + + expect(screen.queryByRole("img")).not.toBeInTheDocument(); + expect(screen.getByText("Embedded image")).toBeInTheDocument(); + }); + it("renders authoritative LLM structure levels for semantic list units", () => { render( { expect(screen.queryAllByText("No.")).toHaveLength(1); }); + it("keeps source indentation after a persisted table unit", () => { + render( + , + ); + + expect(screen.getByText("Nested item")).toHaveAttribute("data-indent-level", "1"); + expect(screen.getByText("Unavailable source unit")).toHaveAttribute("data-indent-level", "0"); + }); + + it("keeps adjacent source tables as separate buyer-facing tables", () => { + render( + ABCD" + + "
    EF
    GH
    " + } + structureUnits={[ + ["A", "B"], + ["C", "D"], + ["E", "F"], + ["G", "H"], + ].map(([left, right], unit_index) => ({ + unit_index, + unit_kind_code: "dom", + unit_label: "tr", + unit_text: `${left} | ${right}`, + indent_level: 0, + indent_source_code: "explicit" as const, + indent_confidence: 1, + indent_evidence: "table row", + }))} + />, + ); + + expect(screen.getAllByRole("table")).toHaveLength(2); + expect(screen.getAllByRole("row")).toHaveLength(4); + }); + it("marks persisted footnotes as footnote evidence", () => { render( { expect(screen.getByText("*Tier 2: note")).toHaveAttribute("data-content-kind", "footnote"); }); + + it("renders persisted image evidence without exposing the internal LLM instruction", () => { + render( + , + ); + + expect(screen.getByText("A process diagram")).toBeInTheDocument(); + expect(screen.getByText("diagram, process")).toBeInTheDocument(); + expect(screen.getByText("Main panel")).toBeInTheDocument(); + expect(screen.queryByText(/This post is an image/)).not.toBeInTheDocument(); + }); + + it("renders pipe-delimited image OCR as a buyer-facing table", () => { + render( + '} + imageContent={[ + { + unit_index: 0, + mime_type: "image/png", + status_code: "described", + extracted_text: "| No. | Item |\n| --- | --- |\n| 1 | Panel |", + caption: "A table image", + tags: [], + }, + ]} + />, + ); + + expect(screen.getByRole("table")).toBeInTheDocument(); + expect(screen.getAllByRole("row")).toHaveLength(2); + expect(screen.getByText("Panel")).toBeInTheDocument(); + }); + + it("keeps source-image placement while showing persisted OCR and caption evidence", () => { + render( + Before

    After

    '} + imageContent={[ + { + unit_index: 1, + mime_type: "image/png", + status_code: "described", + extracted_text: "OCR from the source image", + caption: "Source diagram", + tags: ["diagram"], + }, + ]} + />, + ); + + expect(screen.getByAltText("Source diagram")).toBeInTheDocument(); + expect(screen.getByText("Source diagram")).toBeInTheDocument(); + expect(screen.getByText("OCR from the source image")).toBeInTheDocument(); + expect(screen.getByText("Before").compareDocumentPosition(screen.getByAltText("Source diagram")) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(screen.getByAltText("Source diagram").compareDocumentPosition(screen.getByText("After")) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); }); diff --git a/frontend/src/PostBody.tsx b/frontend/src/PostBody.tsx index d25a902df..6769d0192 100644 --- a/frontend/src/PostBody.tsx +++ b/frontend/src/PostBody.tsx @@ -3,6 +3,90 @@ import { t } from "./i18n"; import type { PostContentUnit, PostImageContent } from "./api"; import type { ReactNode } from "react"; +function parsePipeDelimitedTable(text: string): string[][] | null { + const rows = text + .split(/\r?\n/) + .map((row) => { + const cells = row.split("|").map((cell) => cell.trim()); + if (cells[0] === "") cells.shift(); + if (cells[cells.length - 1] === "") cells.pop(); + return cells; + }) + .filter((row) => !row.every((cell) => /^:?-{3,}:?$/.test(cell))) + .filter((row) => row.length > 1 && row.some(Boolean)); + if (rows.length < 2 || rows.some((row) => row.length !== rows[0].length)) return null; + if (rows[0].length < 2) return null; + return rows; +} + +function renderImageText(text: string) { + const rows = parsePipeDelimitedTable(text); + if (!rows) return

    {text}

    ; + return ( + + + {rows.map((row, rowIndex) => ( + + {row.map((cell, cellIndex) => ( + + ))} + + ))} + +
    {cell}
    + ); +} + +const SAFE_EMBEDDED_IMAGE_SOURCE = + /^data:image\/(?:png|jpe?g|gif|webp|avif|bmp|x-icon|vnd\.microsoft\.icon);base64,[A-Za-z0-9+/]+={0,2}$/i; + +function renderImageEvidence( + index: number, + imageContent?: PostImageContent, + sourceImage?: Extract, +) { + const sourceImageSrc = + sourceImage && SAFE_EMBEDDED_IMAGE_SOURCE.test(sourceImage.src) ? sourceImage.src : undefined; + return ( +
    + {sourceImageSrc ? ( + {imageContent?.caption + ) : null} + {imageContent?.caption || !sourceImageSrc ? ( +
    {imageContent?.caption || t("Embedded image")}
    + ) : null} + {imageContent?.tags.length ? ( +

    + {t("Image tags")}: {imageContent.tags.join(", ")} +

    + ) : null} + {imageContent?.extracted_text ? ( +
    + {t("Text detected in image")} + {renderImageText(imageContent.extracted_text)} +
    + ) : null} + {imageContent?.regions?.length ? ( +
    + {t("Image regions")} +
      + {imageContent.regions.map((region) => ( +
    1. + {region.caption || region.extracted_text || t("Unknown")} + {region.tags.length ? ( + + {t("Image tags")}: {region.tags.join(", ")} + + ) : null} +
    2. + ))} +
    +
    + ) : null} +
    + ); +} + function renderSegment(segment: PostBodySegment, index: number, imageContent?: PostImageContent) { switch (segment.kind) { case "text": @@ -22,33 +106,7 @@ function renderSegment(segment: PostBodySegment, index: number, imageContent?: P

    ); case "image": - return ( -
    - {t("Embedded - {imageContent?.caption ?
    {imageContent.caption}
    : null} - {imageContent?.extracted_text ? ( -
    - {t("Text detected in image")} -

    {imageContent.extracted_text}

    -
    - ) : null} - {imageContent?.regions?.length ? ( -
    - {t("Image regions")} -
      - {imageContent.regions.map((region) => ( -
    1. - {region.caption || region.extracted_text || t("Unknown")} -
    2. - ))} -
    -
    - ) : null} -
    - ); + return renderImageEvidence(index, imageContent, segment); default: { const _exhaustive: never = segment; throw new Error(`unexpected post body segment: ${JSON.stringify(_exhaustive)}`); @@ -64,6 +122,40 @@ function isStructuredTableRow(unit: PostContentUnit): boolean { ); } +/** + * Match a persisted unit to its source-rendering counterpart without relying + * on ordinal position. A table row can occupy a persisted non-text unit while + * its source display is still one text segment, so ordinal matching shifts + * indentation for every later unresolved unit. + */ +function normalizedUnitText(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} + +/** + * Return direct row counts for each source table in document order. + * + * Persisted rows do not currently carry a table identifier. The source body + * is therefore the smallest trustworthy boundary for adjacent tables; when + * its row count disagrees with persisted rows, the renderer falls back to the + * old consecutive-row grouping instead of guessing. + */ +function sourceTableRowGroupSizes(body: string): number[] { + const document = new DOMParser().parseFromString(body, "text/html"); + return Array.from(document.querySelectorAll("table")) + .map((table) => + Array.from(table.children).reduce((count, child) => { + const tagName = child.tagName.toLowerCase(); + if (tagName === "tr") return count + 1; + if (tagName !== "thead" && tagName !== "tbody" && tagName !== "tfoot") return count; + return count + Array.from(child.children).filter( + (row) => row.tagName.toLowerCase() === "tr", + ).length; + }, 0), + ) + .filter((rowCount) => rowCount > 0); +} + function renderStructuredUnits( body: string, structureUnits: PostContentUnit[], @@ -74,10 +166,27 @@ function renderStructuredUnits( ); const rendered: ReactNode[] = []; let imageOrdinal = 0; - let textOrdinal = 0; + const sourceTableGroups = sourceTableRowGroupSizes(body); + const persistedTableRowCount = structureUnits.filter(isStructuredTableRow).length; + const hasTrustworthyTableGroups = + sourceTableGroups.length > 0 && + sourceTableGroups.reduce((total, rowCount) => total + rowCount, 0) === persistedTableRowCount; + let tableGroupOrdinal = 0; const sourceTextSegments = splitPostBody(body).filter( (segment): segment is Extract => segment.kind === "text", ); + const consumedSourceText = new Set(); + const sourceTextForUnit = (unitText: string) => { + const expected = normalizedUnitText(unitText); + const sourceIndex = sourceTextSegments.findIndex( + (segment, candidateIndex) => + !consumedSourceText.has(candidateIndex) && + normalizedUnitText(segment.text) === expected, + ); + if (sourceIndex < 0) return undefined; + consumedSourceText.add(sourceIndex); + return sourceTextSegments[sourceIndex]; + }; let index = 0; while (index < structureUnits.length) { const unit = structureUnits[index]; @@ -87,14 +196,21 @@ function renderStructuredUnits( rendered.push( sourceImage ? renderSegment(sourceImage, index, content) - : renderSegment({ kind: "text", text: unit.unit_text }, index, content), + : renderImageEvidence(index, content), ); index += 1; continue; } if (isStructuredTableRow(unit)) { const rows: PostContentUnit[] = []; - while (index < structureUnits.length && isStructuredTableRow(structureUnits[index])) { + const expectedRowCount = hasTrustworthyTableGroups + ? sourceTableGroups[tableGroupOrdinal++] + : undefined; + while ( + index < structureUnits.length && + isStructuredTableRow(structureUnits[index]) && + (expectedRowCount === undefined || rows.length < expectedRowCount) + ) { rows.push(structureUnits[index]); index += 1; } @@ -113,7 +229,7 @@ function renderStructuredUnits( ); continue; } - const sourceText = sourceTextSegments[textOrdinal++]; + const sourceText = sourceTextForUnit(unit.unit_text); const persistedIndent = unit.indent_level > 0 && (unit.indent_source_code === "explicit" || unit.indent_source_code === "llm") diff --git a/frontend/src/api.ts b/frontend/src/api.ts index aa3a06fa0..6956419b9 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -193,6 +193,7 @@ export interface PostMajorEventAction { requester_actor_name: string | null; processor_actor_name: string | null; evidence_text: string; + project_name?: string | null; } export interface PostProjectMention { @@ -220,12 +221,20 @@ export interface ProjectEvidence { export interface PostAiSummary { post_id: string; korean_summary: string; + summary_status?: "current" | "stale"; + summary_contract_version?: number | null; key_events: string[]; + key_event_details?: PostKeyEvent[]; roles_and_responsibilities: PostRoleResponsibility[]; major_event_actions?: PostMajorEventAction[]; project_mentions?: PostProjectMention[]; } +export interface PostKeyEvent { + event_text: string; + project_name?: string | null; +} + export interface FiveW1HValue { text: string; source: string; @@ -1059,3 +1068,28 @@ export interface RankingList { export function fetchRankings(accessToken: string): Promise { return backendFetch("/api/rankings", accessToken); } + +export async function fetchTenantConfig(accessToken: string): Promise<{ brandName: string }> { + const response = await fetch(`${config.backendBaseUrl}/api/settings`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (!response.ok) { + throw new Error(`Failed to fetch tenant config: ${response.status}`); + } + return response.json(); +} + +export async function updateTenantConfig(accessToken: string, brandName: string): Promise<{ brandName: string }> { + const response = await fetch(`${config.backendBaseUrl}/api/settings`, { + method: "PATCH", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ brandName }), + }); + if (!response.ok) { + throw new Error(`Failed to update tenant config: ${response.status}`); + } + return response.json(); +} diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx new file mode 100644 index 000000000..af098f827 --- /dev/null +++ b/frontend/src/components/AdminPanel.tsx @@ -0,0 +1,63 @@ +import { useState } from "react"; +import { t } from "../i18n"; +import { updateTenantConfig } from "../api"; + +export type AdminPanelProps = { + currentBrandName: string; + onBrandNameChange: (newName: string) => void; + accessToken: string; +}; + +export function AdminPanel({ currentBrandName, onBrandNameChange, accessToken }: AdminPanelProps) { + const [draftName, setDraftName] = useState(currentBrandName); + const [saved, setSaved] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + async function handleSave(e: React.FormEvent) { + e.preventDefault(); + if (draftName.trim()) { + setSaving(true); + setError(null); + try { + const config = await updateTenantConfig(accessToken, draftName.trim()); + onBrandNameChange(config.brandName); + setSaved(true); + setTimeout(() => setSaved(false), 3000); + } catch (err: any) { + setError(err.message || "Failed to update settings"); + } finally { + setSaving(false); + } + } + } + + return ( +
    +
    +

    {t("Admin settings")}

    +
    +
    + + setDraftName(e.target.value)} + style={{ padding: "0.5rem", width: "100%", maxWidth: "400px", marginBottom: "1rem" }} + aria-label={t("Tenant brand name")} + disabled={saving} + /> +
    + + {saved && {t("Settings saved!")}} + {error && {t(error)}} +
    +
    +
    + ); +} diff --git a/frontend/src/components/BuyerNav.tsx b/frontend/src/components/BuyerNav.tsx index 4b5bddc92..b691bfd9c 100644 --- a/frontend/src/components/BuyerNav.tsx +++ b/frontend/src/components/BuyerNav.tsx @@ -1,7 +1,7 @@ import { t } from "../i18n"; import type { ReactNode } from "react"; -export type BuyerDestination = "board" | "customers" | "calendar" | "ask"; +export type BuyerDestination = "board" | "customers" | "calendar" | "ask" | "admin"; export type BuyerNavProps = { destination: BuyerDestination; @@ -9,13 +9,14 @@ export type BuyerNavProps = { tools?: ReactNode; }; -const ITEMS: BuyerDestination[] = ["board", "customers", "calendar", "ask"]; +const ITEMS: BuyerDestination[] = ["board", "customers", "calendar", "ask", "admin"]; const LABELS: Record = { board: "Board", customers: "Customer master", calendar: "Calendar", ask: "Ask Agent", + admin: "Admin", }; export function BuyerNav({ destination, onChange, tools }: BuyerNavProps) { diff --git a/frontend/src/i18n.test.ts b/frontend/src/i18n.test.ts index 80d6d5085..aed9240c1 100644 --- a/frontend/src/i18n.test.ts +++ b/frontend/src/i18n.test.ts @@ -22,6 +22,7 @@ describe("i18n", () => { "unresolved", "Keymen", "Unknown", + "Image tags", "Counterparties", "due", "Activity", diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index dca426b06..2fb01c150 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -172,8 +172,8 @@ const TRANSLATIONS: Partial>> = { "Evidence-grounded questions": "근거 기반 질문", "Questions use authorized posts and their evidence.": "질문은 권한이 있는 글과 그 근거를 사용합니다.", "Ask a question": "질문 입력", - Ask: "질문하기", - "Asking...": "질문하는 중...", + Ask: "질의", + "Asking...": "질의 중...", Answer: "답변", "Cited posts": "인용된 글", "Search related posts": "관련 글 검색", @@ -214,6 +214,8 @@ const TRANSLATIONS: Partial>> = { Activity: "활동", Tickets: "티켓", Summary: "요약", + "Last saved summary shown. Retry semantic refresh.": "마지막 저장 요약을 표시합니다. 의미 기반 새로고침을 다시 시도하세요.", + "Retry summary refresh": "요약 새로고침 재시도", "5W1H": "5W1H", Who: "누가", What: "무엇을", @@ -251,7 +253,7 @@ const TRANSLATIONS: Partial>> = { "No tickets yet.": "아직 티켓이 없습니다.", "New ticket title": "새 티켓 제목", "Due date": "기한", - "Create ticket": "티켓 만들기", + "Create ticket": "티켓 작성", "Loading activity...": "활동을 불러오는 중...", "No activity yet.": "아직 활동이 없습니다.", "Loading affiliate tree...": "소속 트리를 불러오는 중...", @@ -269,22 +271,23 @@ const TRANSLATIONS: Partial>> = { "Next page": "다음 페이지", Page: "페이지", "Rebuild lineage": "계보 다시 만들기", - "Extracting...": "추출하는 중...", - "Extract Keymen": "핵심 담당자 새로 고침", + "Extracting...": "도출 중...", + "Extract Keymen": "Keyman 도출", Resolve: "해결", "Resolving...": "해결하는 중...", "This hint could not be resolved to a corroborated organization name.": "이 힌트를 검증된 조직명으로 해결할 수 없습니다.", "Evaluating...": "평가하는 중...", - "Evaluate post": "평가 새로 고침", - "Verifying...": "확인하는 중...", - "Verify against web search": "외부 근거 확인", + "Evaluate post": "게시글 평가", + "Verifying...": "검증 중...", + "Verify against web search": "웹 검색 검증", "Deriving...": "찾는 중...", - "Derive commitment": "약속 찾기", + "Derive commitment": "Commitment 도출", "Creating...": "생성하는 중...", "Embedded image": "삽입 이미지", "Text detected in image": "이미지에서 인식된 텍스트", "Image regions": "이미지 영역", + "Image tags": "이미지 태그", "Embedded image could not be decoded. Re-export the source post and open it again.": "첨부 이미지를 해독할 수 없습니다. 원문을 다시 내보내고 다시 여세요.", "What happened between these events?": "이 사건들 사이에 무슨 일이 있었나요?", @@ -340,7 +343,13 @@ const TRANSLATIONS: Partial>> = { "{post} evidence is current. Read Event Lineage on that post next.": "{post} 근거가 현재 표시되어 있습니다. 다음으로 해당 글의 이벤트 계보를 읽으세요.", Close: "닫기", - Refresh: "새로 고침", + Refresh: "조회", + "Settings saved!": "설정이 저장되었습니다!", + "Save settings": "설정 저장", + "Saving...": "저장 중...", + "Tenant brand name": "테넌트 브랜드명", + "Admin settings": "관리자 설정", + "Admin": "관리자", "Open post": "글 열기", "Post actions": "글 동작", "Permanent link": "영구 링크", @@ -558,6 +567,8 @@ const TRANSLATIONS: Partial>> = { Activity: "活动", Tickets: "工单", Summary: "摘要", + "Last saved summary shown. Retry semantic refresh.": "正在显示上次保存的摘要。请重试语义刷新。", + "Retry summary refresh": "重试摘要刷新", "5W1H": "5W1H", Who: "谁", What: "什么", @@ -628,6 +639,7 @@ const TRANSLATIONS: Partial>> = { "Embedded image": "嵌入图像", "Text detected in image": "图像中识别的文字", "Image regions": "图像区域", + "Image tags": "图像标签", "Embedded image could not be decoded. Re-export the source post and open it again.": "无法解码嵌入图像。请重新导出原始文章后再打开。", "What happened between these events?": "这些事件之间发生了什么?", @@ -684,6 +696,12 @@ const TRANSLATIONS: Partial>> = { "{post} 的证据已显示。接下来查看该文章的事件谱系。", Close: "关闭", Refresh: "刷新", + "Settings saved!": "设置已保存!", + "Save settings": "保存设置", + "Saving...": "保存中...", + "Tenant brand name": "租户品牌名称", + "Admin settings": "管理员设置", + "Admin": "管理员", "Open post": "打开文章", "Post actions": "文章操作", "Permanent link": "永久链接", @@ -925,6 +943,8 @@ const TRANSLATIONS: Partial>> = { Activity: "アクティビティ", Tickets: "チケット", Summary: "概要", + "Last saved summary shown. Retry semantic refresh.": "保存済みの最新の要約を表示しています。意味更新を再試行してください。", + "Retry summary refresh": "要約の更新を再試行", Evidence: "証拠", "Post quality (IRT)": "投稿品質(IRT)", Counterparties: "関係者", @@ -986,6 +1006,7 @@ const TRANSLATIONS: Partial>> = { "Embedded image": "埋め込み画像", "Text detected in image": "画像から認識されたテキスト", "Image regions": "画像領域", + "Image tags": "画像タグ", "Embedded image could not be decoded. Re-export the source post and open it again.": "埋め込み画像をデコードできませんでした。原文を再エクスポートして、もう一度開いてください。", "What happened between these events?": "これらのイベントの間に何が起きましたか?", @@ -1027,6 +1048,12 @@ const TRANSLATIONS: Partial>> = { "{post}の証拠が表示されています。次にその投稿のイベント系譜を確認してください。", Close: "閉じる", Refresh: "更新", + "Settings saved!": "設定が保存されました!", + "Save settings": "設定を保存", + "Saving...": "保存中...", + "Tenant brand name": "テナントブランド名", + "Admin settings": "管理者設定", + "Admin": "管理者", "Open post": "投稿を開く", "Post actions": "投稿操作", "Permanent link": "固定リンク", @@ -1268,6 +1295,8 @@ const TRANSLATIONS: Partial>> = { Activity: "Hoạt động", Tickets: "Phiếu công việc", Summary: "Tóm tắt", + "Last saved summary shown. Retry semantic refresh.": "Đang hiển thị bản tóm tắt đã lưu gần nhất. Hãy thử lại việc làm mới ngữ nghĩa.", + "Retry summary refresh": "Thử lại việc làm mới bản tóm tắt", Evidence: "Bằng chứng", "Post quality (IRT)": "Chất lượng bài viết (IRT)", Counterparties: "Các bên liên quan", @@ -1329,6 +1358,7 @@ const TRANSLATIONS: Partial>> = { "Embedded image": "Hình ảnh nhúng", "Text detected in image": "Văn bản nhận dạng trong hình ảnh", "Image regions": "Các vùng trong hình ảnh", + "Image tags": "Thẻ hình ảnh", "Embedded image could not be decoded. Re-export the source post and open it again.": "Không thể giải mã hình ảnh nhúng. Hãy xuất lại bài viết gốc rồi mở lại.", "What happened between these events?": "Điều gì đã xảy ra giữa các sự kiện này?", @@ -1370,6 +1400,12 @@ const TRANSLATIONS: Partial>> = { "Bằng chứng của {post} đang được hiển thị. Hãy xem Dòng sự kiện của bài viết đó tiếp theo.", Close: "Đóng", Refresh: "Làm mới", + "Settings saved!": "Đã lưu cài đặt!", + "Save settings": "Lưu cài đặt", + "Saving...": "Đang lưu...", + "Tenant brand name": "Tên thương hiệu khách thuê", + "Admin settings": "Cài đặt quản trị viên", + "Admin": "Quản trị viên", "Open post": "Mở bài viết", "Post actions": "Thao tác bài viết", "Permanent link": "Liên kết cố định", diff --git a/frontend/src/index.css b/frontend/src/index.css index 011ad42fd..d4c7db546 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -3,7 +3,10 @@ :root { --text: var(--color-text); --text-h: var(--color-text-heading); + --text-muted: var(--color-text); --bg: var(--color-background); + --surface: var(--color-background); + --surface-muted: var(--color-palette-gray-100); --border: var(--color-border); --code-bg: var(--color-code-background); --accent: var(--color-accent); @@ -18,12 +21,13 @@ --shadow: rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; - --sans: system-ui, 'Segoe UI', Roboto, sans-serif; - --heading: system-ui, 'Segoe UI', Roboto, sans-serif; + /* Standard System Fonts (§4.2 – 노토산스 기본 적용) */ + --sans: "Noto Sans KR", "Noto Sans", "Nanum Gothic", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --heading: "Noto Sans KR", "Noto Sans", "Nanum Gothic", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; --mono: ui-monospace, Consolas, monospace; - font: 18px/145% var(--sans); - letter-spacing: 0.18px; + font: 16px/150% var(--sans); + letter-spacing: -0.01em; color-scheme: light dark; color: var(--text); background: var(--bg); @@ -32,8 +36,12 @@ -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; + /* Responsive font scaling (§2.1.2) */ @media (max-width: 1024px) { - font-size: 16px; + font-size: 15px; + } + @media (max-width: 768px) { + font-size: 14px; } } @@ -41,7 +49,10 @@ :root { --text: var(--color-text); --text-h: var(--color-text-heading); + --text-muted: var(--color-text); --bg: var(--color-background); + --surface: var(--color-background); + --surface-muted: #1f2028; --border: var(--color-border); --code-bg: var(--color-code-background); --accent: var(--color-accent); @@ -57,47 +68,59 @@ } } +/* Shell container – 1920px max width (§2.1.1) */ #root { - width: 1126px; - max-width: 100%; + width: 100%; + max-width: var(--layout-max-width); margin: 0 auto; - text-align: center; - border-inline: 1px solid var(--border); - min-height: 100svh; + min-height: 100vh; display: flex; flex-direction: column; box-sizing: border-box; + overflow-x: hidden; } body { margin: 0; + padding: 0; + background: var(--bg); + color: var(--text); } +/* Heading Standards (§4.2 – Gothic bold) */ h1, -h2 { +h2, +h3, +h4, +h5, +h6 { font-family: var(--heading); - font-weight: 500; + font-weight: 700; color: var(--text-h); + letter-spacing: -0.02em; } h1 { - font-size: 56px; - letter-spacing: -1.68px; - margin: 32px 0; + font-size: 2rem; + line-height: 1.25; + margin: 1rem 0; @media (max-width: 1024px) { - font-size: 36px; - margin: 20px 0; + font-size: 1.75rem; + } + @media (max-width: 768px) { + font-size: 1.5rem; } } + h2 { - font-size: 24px; - line-height: 118%; - letter-spacing: -0.24px; - margin: 0 0 8px; + font-size: 1.35rem; + line-height: 1.3; + margin: 0 0 0.5rem; @media (max-width: 1024px) { - font-size: 20px; + font-size: 1.2rem; } } + p { margin: 0; } @@ -111,8 +134,71 @@ code, } code { - font-size: 15px; - line-height: 135%; - padding: 4px 8px; + font-size: 0.9em; + line-height: 1.4; + padding: 2px 6px; background: var(--code-bg); } + +/* Base Form & Input Standards (§3.1.4) */ +input, +select, +textarea { + font-family: inherit; + font-size: inherit; + box-sizing: border-box; +} + +input:focus, +select:focus, +textarea:focus { + outline: 2px solid var(--color-focus-border); + outline-offset: 1px; +} + +/* Base Table Standards (§3.1 공통) */ +table { + width: 100%; + border-collapse: collapse; +} + +/* §3.1.2 – table title center aligned, bold */ +th { + background-color: var(--color-table-th-bg); + color: var(--color-table-th-text); + font-weight: 700; + text-align: center; + padding: 0.6rem 0.8rem; + border: 1px solid var(--color-table-border); +} + +td { + padding: 0.55rem 0.8rem; + border: 1px solid var(--color-table-border); + text-align: left; +} + +/* §3.1.5 – Table content alignment rules */ +td.text-number, +td.align-right { + text-align: right; + font-variant-numeric: tabular-nums; +} + +td.text-code, +td.align-center { + text-align: center; +} + +/* §3.1.7 – Required field indicator */ +.required-mark { + color: var(--color-required); + font-weight: 700; + margin-right: 0.2em; +} + +/* §3.1.6 – Number formatting */ +.format-currency { + font-variant-numeric: tabular-nums; + text-align: right; +} diff --git a/frontend/src/oidcReturnUrl.test.ts b/frontend/src/oidcReturnUrl.test.ts index 5c234d835..de6e95502 100644 --- a/frontend/src/oidcReturnUrl.test.ts +++ b/frontend/src/oidcReturnUrl.test.ts @@ -6,7 +6,10 @@ import { } from "./oidcReturnUrl"; describe("OIDC return URL handling", () => { - beforeEach(() => window.sessionStorage.clear()); + beforeEach(() => { + window.sessionStorage.clear(); + window.localStorage.clear(); + }); it("keeps a post deep link and rejects external destinations", () => { expect(returnUrlFromLocation({ pathname: "/", search: "?post=abc", hash: "" })).toBe( @@ -16,11 +19,51 @@ describe("OIDC return URL handling", () => { }); it("restores an object or serialized OIDC state before storage fallback", () => { + rememberOidcReturnUrl("/?post=stored-before-direct"); + expect(restoreOidcReturnUrl("/?post=from-direct-state")).toBe( + "/?post=from-direct-state", + ); + rememberOidcReturnUrl("/?post=stored"); expect(restoreOidcReturnUrl({ returnUrl: "/?post=from-object" })).toBe("/?post=from-object"); rememberOidcReturnUrl("/?post=stored-again"); expect(restoreOidcReturnUrl('{"returnUrl":"/?post=from-json"}')).toBe("/?post=from-json"); expect(window.sessionStorage.getItem("lineageweave.oidc.returnUrl")).toBeNull(); + expect(window.localStorage.getItem("lineageweave.oidc.returnUrl")).toBeNull(); + }); + + it("rejects oversized and recursively encoded state without exhausting the stack", () => { + rememberOidcReturnUrl("/?post=stored-fallback"); + const oversizedNestedState = `${"[".repeat(5000)}0${"]".repeat(5000)}`; + + expect(restoreOidcReturnUrl(oversizedNestedState)).toBe("/?post=stored-fallback"); + + rememberOidcReturnUrl("/?post=stored-after-encoded-state"); + const recursivelyEncoded = JSON.stringify(JSON.stringify({ returnUrl: "/?post=nested" })); + expect(restoreOidcReturnUrl(recursivelyEncoded)).toBe( + "/?post=stored-after-encoded-state", + ); + + rememberOidcReturnUrl("/?post=stored-after-invalid-json"); + expect(restoreOidcReturnUrl("not-json-state")).toBe( + "/?post=stored-after-invalid-json", + ); + }); + + it("rejects oversized direct paths before storing or restoring them", () => { + const oversizedPath = `/?post=${"a".repeat(4096)}`; + + rememberOidcReturnUrl(oversizedPath); + + expect(window.sessionStorage.getItem("lineageweave.oidc.returnUrl")).toBeNull(); + expect(restoreOidcReturnUrl({ returnUrl: oversizedPath })).toBe("/"); + }); + + it("restores a deep link from local storage when session storage is empty", () => { + window.localStorage.setItem("lineageweave.oidc.returnUrl", "/?post=from-local-storage"); + + expect(restoreOidcReturnUrl(undefined)).toBe("/?post=from-local-storage"); + expect(window.localStorage.getItem("lineageweave.oidc.returnUrl")).toBeNull(); }); }); diff --git a/frontend/src/oidcReturnUrl.ts b/frontend/src/oidcReturnUrl.ts index 60962e011..027d2f55b 100644 --- a/frontend/src/oidcReturnUrl.ts +++ b/frontend/src/oidcReturnUrl.ts @@ -1,9 +1,14 @@ export const OIDC_RETURN_URL_STORAGE_KEY = "lineageweave.oidc.returnUrl"; +const MAX_OIDC_RETURN_URL_LENGTH = 4096; type UrlLike = Pick; function isSafeReturnUrl(value: string): boolean { - return value.startsWith("/") && !value.startsWith("//"); + return ( + value.length <= MAX_OIDC_RETURN_URL_LENGTH && + value.startsWith("/") && + !value.startsWith("//") + ); } export function returnUrlFromLocation(location: UrlLike = window.location): string { @@ -18,32 +23,50 @@ export function rememberOidcReturnUrl(value: string): void { } catch { // OIDC state remains the fallback when session storage is unavailable. } + try { + window.localStorage.setItem(OIDC_RETURN_URL_STORAGE_KEY, value); + } catch { + // The OIDC state and session storage remain the fallbacks. + } } function stateReturnUrl(state: unknown): string { - if (typeof state === "string") { + let candidate = state; + if (typeof candidate === "string") { + if (isSafeReturnUrl(candidate)) return candidate; + if (candidate.length > MAX_OIDC_RETURN_URL_LENGTH) return ""; try { - return stateReturnUrl(JSON.parse(state)); + candidate = JSON.parse(candidate); } catch { - return isSafeReturnUrl(state) ? state : ""; + return ""; } } - if (typeof state !== "object" || state === null || !("returnUrl" in state)) return ""; - const value = (state as { returnUrl?: unknown }).returnUrl; + if (typeof candidate !== "object" || candidate === null || !("returnUrl" in candidate)) { + return ""; + } + const value = (candidate as { returnUrl?: unknown }).returnUrl; return typeof value === "string" && isSafeReturnUrl(value) ? value : ""; } export function restoreOidcReturnUrl(state: unknown): string { const fromState = stateReturnUrl(state); - let stored = ""; + let sessionStored = ""; + let localStored = ""; try { - stored = window.sessionStorage.getItem(OIDC_RETURN_URL_STORAGE_KEY) ?? ""; + sessionStored = window.sessionStorage.getItem(OIDC_RETURN_URL_STORAGE_KEY) ?? ""; window.sessionStorage.removeItem(OIDC_RETURN_URL_STORAGE_KEY); + } catch { + // Fall through to local storage or the current path. + } + try { + localStored = window.localStorage.getItem(OIDC_RETURN_URL_STORAGE_KEY) ?? ""; + window.localStorage.removeItem(OIDC_RETURN_URL_STORAGE_KEY); } catch { // Fall through to the current path. } if (fromState) return fromState; - if (isSafeReturnUrl(stored)) return stored; + if (isSafeReturnUrl(sessionStored)) return sessionStored; + if (isSafeReturnUrl(localStored)) return localStored; return new URLSearchParams(window.location.search).has("post") ? returnUrlFromLocation() : window.location.pathname; diff --git a/frontend/src/postBodyDisplay.ts b/frontend/src/postBodyDisplay.ts index 7eb13c0e1..7e942bb48 100644 --- a/frontend/src/postBodyDisplay.ts +++ b/frontend/src/postBodyDisplay.ts @@ -90,6 +90,7 @@ function indentMarker(width: number): string { } function stripHtmlTags(text: string): string { + text = text.replace(/]*>(.*?)<\/sup>/gi, "^$1"); const listIndentWidths: number[] = []; let listIndent = 0; const withBoundaries = text diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css index 6c2bfa4f4..d9e08bd18 100644 --- a/frontend/src/styles/tokens.css +++ b/frontend/src/styles/tokens.css @@ -1,18 +1,70 @@ :root { + /* Primary & Accent Colors (§4.1 – CI/BI 규정집 준용) */ + --color-primary: #034ea2; + --color-primary-dark: #2e008b; + --color-primary-light: #0047bb; + --color-accent-gold: #876d4b; + --color-accent-light-gold: #ad7c59; + --color-accent-silver: #898c8e; + --color-accent-light-silver: #c8c8c8; + --color-accent-orange: #ff595a; + + /* Standard Palette (§3.1 공통 – 색상 예시) */ + --color-palette-blue-deep: #0071bc; + --color-palette-blue-mid: #448ccb; + --color-palette-blue-light: #adcafc; + --color-palette-gray-lightest: #eeeef0; + --color-palette-white: #ffffff; + --color-palette-gray-400: #a5a5a5; + --color-palette-gray-300: #bfbfbf; + --color-palette-gray-200: #d8d8d8; + --color-palette-gray-100: #f2f2f2; + + /* Table & Form Standard Tokens (§3.1) */ + --color-table-th-bg: #eeeef0; + --color-table-th-border: #d8d8d8; + --color-table-th-text: #08060d; + --color-table-row-hover: #f2f2f2; + --color-table-border: #e5e4e7; + + /* Header & Footer Tokens (§2.2) */ + --color-header-bg: #ffffff; + --color-header-border: #d8d8d8; + --color-footer-bg: #f9f9fa; + --color-footer-text: #898c8e; + --color-footer-border: #e5e4e7; + + /* Focus Ring (§3.1.4 – cursor input form) */ + --color-focus-ring: rgba(3, 78, 162, 0.35); + --color-focus-border: #034ea2; + + /* Button Tokens (§4.3) */ + --color-btn-primary-bg: #034ea2; + --color-btn-primary-hover: #0047bb; + --color-btn-primary-text: #ffffff; + --color-btn-secondary-bg: #ffffff; + --color-btn-secondary-border: #c8c8c8; + --color-btn-secondary-hover: #eeeef0; + --color-btn-secondary-text: #08060d; + + /* Text & Background */ --color-text: #6b6375; --color-text-heading: #08060d; --color-background: #fff; --color-border: #e5e4e7; --color-code-background: #f4f3ec; - --color-accent: #aa3bff; - --color-accent-background: rgba(170, 59, 255, 0.1); - --color-accent-border: rgba(170, 59, 255, 0.5); + --color-accent: #034ea2; + --color-accent-background: rgba(3, 78, 162, 0.08); + --color-accent-border: rgba(3, 78, 162, 0.4); --color-chip-border: #3335; --color-border-subtle: #3335; --color-accent-info: #2563eb; --color-accent-info-background: rgba(37, 99, 235, 0.2); --color-accent-secondary: #7c3aed; --color-accent-secondary-background: rgba(124, 58, 237, 0.2); + --color-status-alert: #b91c1c; + + /* Badge Tokens (ADR 0099) */ --badge-actor-person-bg: #e8eaf6; --badge-actor-person-text: #303f9f; --badge-actor-organization-bg: #fff3e0; @@ -25,6 +77,8 @@ --badge-status-success-text: #155724; --badge-status-danger-bg: #f8d7da; --badge-status-danger-text: #721c24; + + /* Spacing & Radius Tokens */ --space-chip-inline: 0.6rem; --space-chip-block: 0.1rem; --space-chip-gap: 0.3rem; @@ -32,30 +86,99 @@ --space-control-gap: 0.35rem; --size-control-min: 24px; --radius-chip: 999px; - --radius-control: 8px; + --radius-control: 6px; --font-size-close: 1.5rem; --font-family-chip: ui-monospace, Consolas, monospace; --font-size-badge: 0.75rem; --space-panel-block: 0.75rem; --radius-panel: 0.5rem; + + /* Layout & Breakpoint Tokens (§2.1 – 화면 해상도 / 반응형) */ + --breakpoint-phone: 768px; + --breakpoint-tablet: 1024px; + --breakpoint-pc: 1280px; + --breakpoint-max: 1920px; + + /* Shell Layout Tokens (§2.1 Resolution & §2.2 Identity) */ + --layout-max-width: 1920px; + --layout-content-width: 1280px; + --layout-min-width: 1024px; + --header-height: 56px; + --gnb-height: 48px; + --footer-min-height: 64px; + --drawer-width: 280px; + + /* Z-Index Layers */ + --z-header: 100; + --z-gnb-pulldown: 90; + --z-drawer-backdrop: 200; + --z-drawer: 210; + --z-modal-backdrop: 300; + --z-modal: 310; + --z-evidence-panel: 250; + + /* Required Field Indicator */ + --color-required: #b91c1c; + + /* GNB Active Indicator */ + --gnb-active-indicator-height: 3px; + --gnb-active-indicator-color: var(--color-primary); + + /* Drawer Menu Tokens */ + --color-drawer-bg: var(--color-background); + --color-drawer-border: var(--color-border); + --color-drawer-overlay: rgba(0, 0, 0, 0.5); } @media (prefers-color-scheme: dark) { :root { + --color-primary: #448ccb; + --color-primary-dark: #2e008b; + --color-primary-light: #60a5fa; + --color-accent-gold: #ad7c59; + --color-accent-light-gold: #ad7c59; + --color-accent-silver: #898c8e; + --color-accent-light-silver: #c8c8c8; + --color-accent-orange: #ff595a; + + --color-table-th-bg: #22232c; + --color-table-th-border: #2e303a; + --color-table-th-text: #f3f4f6; + --color-table-row-hover: #1c1d25; + --color-table-border: #2e303a; + + --color-header-bg: #16171d; + --color-header-border: #2e303a; + --color-footer-bg: #131419; + --color-footer-text: #9ca3af; + --color-footer-border: #2e303a; + + --color-focus-ring: rgba(96, 165, 250, 0.35); + --color-focus-border: #60a5fa; + + --color-btn-primary-bg: #034ea2; + --color-btn-primary-hover: #0047bb; + --color-btn-primary-text: #ffffff; + --color-btn-secondary-bg: #1f2028; + --color-btn-secondary-border: #3b3d4a; + --color-btn-secondary-hover: #2e303a; + --color-btn-secondary-text: #f3f4f6; + --color-text: #9ca3af; --color-text-heading: #f3f4f6; --color-background: #16171d; --color-border: #2e303a; --color-code-background: #1f2028; - --color-accent: #c084fc; - --color-accent-background: rgba(192, 132, 252, 0.15); - --color-accent-border: rgba(192, 132, 252, 0.5); + --color-accent: #60a5fa; + --color-accent-background: rgba(96, 165, 250, 0.15); + --color-accent-border: rgba(96, 165, 250, 0.5); --color-chip-border: #9ca3af; --color-border-subtle: rgba(255, 255, 255, 0.2); --color-accent-info: #60a5fa; --color-accent-info-background: rgba(96, 165, 250, 0.2); --color-accent-secondary: #a78bfa; --color-accent-secondary-background: rgba(167, 139, 250, 0.2); + --color-status-alert: #f87171; --badge-actor-person-bg: rgba(63, 81, 181, 0.25); --badge-actor-person-text: #b3bcf5; --badge-actor-organization-bg: rgba(154, 52, 18, 0.25); @@ -68,5 +191,9 @@ --badge-status-success-text: #86e29b; --badge-status-danger-bg: rgba(114, 28, 36, 0.3); --badge-status-danger-text: #f5a3ab; + + --color-drawer-bg: #16171d; + --color-drawer-border: #2e303a; + --color-drawer-overlay: rgba(0, 0, 0, 0.65); } } diff --git a/lineageweave/chunking.py b/lineageweave/chunking.py index b7afcbe3a..6967ddbfd 100644 --- a/lineageweave/chunking.py +++ b/lineageweave/chunking.py @@ -68,6 +68,10 @@ "ul", "ol", "li", + "footnote", + "endnote", + "w:footnote", + "w:endnote", "tr", "blockquote", "h1", @@ -100,6 +104,32 @@ _FOOTNOTE_START = re.compile(r"^(?:[*†‡](?=\S)|\[\d{1,3}\]\s+\S)") +def _is_footnote_block(tag: str, attrs: list[tuple[str, str | None]]) -> bool: + """Recognize semantic footnote markup emitted by HTML and Word exports.""" + if tag.casefold().rsplit(":", 1)[-1] in {"footnote", "endnote"}: + return True + values = " ".join( + value or "" + for name, value in attrs + if name.casefold() in {"class", "id", "role", "data-role"} + ).casefold() + return "footnote" in values or "endnote" in values + + +def _is_footnote_reference(attrs: list[tuple[str, str | None]]) -> bool: + """Recognize a Word footnote-definition backlink, not its body citation.""" + values = { + name.casefold(): (value or "").casefold() + for name, value in attrs + if name.casefold() in {"href", "id", "name"} + } + href = values.get("href", "") + anchor_values = (values.get("id", ""), values.get("name", "")) + return "ftnref" in href and any( + "ftn" in value and "ftnref" not in value for value in anchor_values + ) + + def normalize_semantic_text(text: str) -> str: """Remove visual hanging-indent breaks without changing source content.""" lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n") @@ -128,7 +158,11 @@ def _source_indent_width(text: str) -> int: def _length_to_indent_units(value: str) -> int: """Convert common CSS/XML lengths to a comparable eight-pixel unit.""" - match = re.fullmatch(r"\s*([+-]?(?:\d+\.?\d*|\.\d+))\s*(px|pt|em|rem|in|cm|mm|%)?\s*", value, re.I) + match = re.fullmatch( + r"\s*([+-]?(?:\d+\.?\d*|\.\d+))\s*(px|pt|em|rem|in|cm|mm|%)?\s*", + value, + re.IGNORECASE, + ) if match is None: return 0 amount = float(match.group(1)) @@ -170,7 +204,7 @@ def _declared_indent_width(tag: str, attrs: list[tuple[str, str | None]]) -> int for match in re.finditer( r"(?:^|;)\s*(?:margin-left|padding-left|padding-inline-start|text-indent)\s*:\s*([^;]+)", style, - re.I, + re.IGNORECASE, ): width += _length_to_indent_units(match.group(1)) # A real editor (Word paste, Outlook compose) declares indentation with @@ -179,7 +213,9 @@ def _declared_indent_width(tag: str, attrs: list[tuple[str, str | None]]) -> int # every nested
  • in a real body used only the shorthand, so its # indentation silently read as 0 and every nesting level collapsed flat # (live bug, 2026-08-19). - for match in re.finditer(r"(?:^|;)\s*(?:margin|padding)\s*:\s*([^;]+)", style, re.I): + for match in re.finditer( + r"(?:^|;)\s*(?:margin|padding)\s*:\s*([^;]+)", style, re.IGNORECASE + ): width += _length_to_indent_units(_shorthand_left_value(match.group(1))) for name, value in attrs: if name in {"w:left", "w:start", "w:firstline"} and value: @@ -225,6 +261,9 @@ class Chunk: indent_width: source indentation in semantic units, retained as structural metadata while presentation whitespace is removed from ``text``. + declared_indent_width: indentation declared by HTML/CSS/OOXML or a + nested list container. Source-only leading spaces are excluded so + callers can distinguish authored structure from visual alignment. """ text: str @@ -234,6 +273,7 @@ class Chunk: image_data: bytes | None = field(default=None, compare=True) style: str | None = None indent_width: int = 0 + declared_indent_width: int = 0 def chunk_by_paragraph(text: str) -> list[Chunk]: @@ -301,13 +341,13 @@ class _BlockTextExtractor(HTMLParser): def __init__(self) -> None: super().__init__() - self._stack: list[tuple[str, list[str], str | None, int]] = [] + self._stack: list[tuple[str, list[str], str | None, int, bool]] = [] self._unscoped_buffer: list[str] = [] # Each entry is ("text", str, tag_name, style) or # ("image", (mime_type, bytes), "", None) -- a single sequence in # true document order, so an image's index among its siblings # reflects where it actually sat. - self._finished: list[tuple[str, object, str, str | None, int]] = [] + self._finished: list[tuple[str, object, str, str | None, int, int]] = [] def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: """Collect relevant text state when an HTML start tag is encountered.""" @@ -318,20 +358,25 @@ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None if src: decoded = _decode_data_uri_image(src) if decoded is not None: - self._finished.append(("image", decoded, "", None, 0)) + self._finished.append(("image", decoded, "", None, 0, 0)) return if tag in {"br", "w:br"} and self._stack: self._stack[-1][1].append("\n") return if tag == "w:ind" and self._stack: - tag_name, buffer, style, indent_width = self._stack[-1] + tag_name, buffer, style, indent_width, is_footnote = self._stack[-1] self._stack[-1] = ( tag_name, buffer, style, indent_width + _declared_indent_width(tag, attrs), + is_footnote, ) return + if tag == "a" and self._stack and _is_footnote_reference(attrs): + tag_name, buffer, style, indent_width, _ = self._stack[-1] + self._stack[-1] = (tag_name, buffer, style, indent_width, True) + return if tag in _TABLE_CELL_TAGS: if self._stack and self._stack[-1][0] in _TABLE_ROW_TAGS and self._stack[-1][1]: self._stack[-1][1].append(" | ") @@ -345,7 +390,12 @@ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None if self._stack: self._flush_current_buffer() style = next((value for name, value in attrs if name == "style" and value), None) - self._stack.append((tag, [], style, _declared_indent_width(tag, attrs))) + is_footnote = _is_footnote_block(tag, attrs) or any( + entry[4] for entry in self._stack + ) + self._stack.append( + (tag, [], style, _declared_indent_width(tag, attrs), is_footnote) + ) def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: """Handle self-closing block tags without losing XML indentation state.""" @@ -357,24 +407,30 @@ def handle_endtag(self, tag: str) -> None: """Close the relevant text state when an HTML end tag is encountered.""" if tag in _DOM_BLOCK_TAGS and self._stack and self._stack[-1][0] == tag: declared_width = sum(entry[3] for entry in self._stack) - tag_name, buffer, style, _ = self._stack.pop() - self._finish_block(tag_name, buffer, style, declared_width) + tag_name, buffer, style, _, is_footnote = self._stack.pop() + self._finish_block(tag_name, buffer, style, declared_width, is_footnote) def _flush_current_buffer(self) -> None: """Emit direct parent text before a nested block or embedded image.""" - tag_name, buffer, style, _ = self._stack[-1] + tag_name, buffer, style, indent_width, is_footnote = self._stack[-1] if not buffer: return - self._stack[-1] = (tag_name, [], style, self._stack[-1][3]) + self._stack[-1] = (tag_name, [], style, indent_width, is_footnote) self._finish_block( tag_name, buffer, style, sum(entry[3] for entry in self._stack), + is_footnote, ) def _finish_block( - self, tag_name: str, buffer: list[str], style: str | None, declared_width: int + self, + tag_name: str, + buffer: list[str], + style: str | None, + declared_width: int, + is_footnote: bool = False, ) -> None: """Emit one block buffer, including a block closed only at EOF.""" raw_text = "".join(buffer) @@ -382,9 +438,16 @@ def _finish_block( text = normalize_semantic_text(raw_unit) if text: indent_width = declared_width + source_indent - label = "footnote" if _FOOTNOTE_START.match(text) else tag_name + label = "footnote" if is_footnote or _FOOTNOTE_START.match(text) else tag_name self._finished.append( - ("text", text, label, style, indent_width) + ( + "text", + text, + label, + style, + indent_width, + declared_width, + ) ) def handle_data(self, data: str) -> None: @@ -402,16 +465,18 @@ def handle_data(self, data: str) -> None: elif text.strip() or had_nbsp: self._unscoped_buffer.append(text) - def finished(self) -> list[tuple[str, object, str, str | None, int]]: + def finished(self) -> list[tuple[str, object, str, str | None, int, int]]: """Return the normalized records collected from the HTML fragment.""" while self._stack: declared_width = sum(entry[3] for entry in self._stack) - tag_name, buffer, style, _ = self._stack.pop() - self._finish_block(tag_name, buffer, style, declared_width) + tag_name, buffer, style, _, is_footnote = self._stack.pop() + self._finish_block(tag_name, buffer, style, declared_width, is_footnote) if not self._finished: fallback = normalize_semantic_text("".join(self._unscoped_buffer)) if fallback: - return [("text", fallback, "", None, _source_indent_width(fallback))] + return [ + ("text", fallback, "", None, _source_indent_width(fallback), 0) + ] return self._finished @@ -520,7 +585,14 @@ def chunk_by_dom(html: str) -> list[Chunk]: parser.feed(html) entries = parser.finished() chunks: list[Chunk] = [] - for index, (kind, value, tag_name, style, indent_width) in enumerate(entries): + for index, ( + kind, + value, + tag_name, + style, + indent_width, + declared_indent_width, + ) in enumerate(entries): if kind == "text": chunks.append( Chunk( @@ -530,6 +602,7 @@ def chunk_by_dom(html: str) -> list[Chunk]: label=tag_name, style=style, indent_width=indent_width, + declared_indent_width=declared_indent_width, ) ) else: @@ -558,6 +631,7 @@ def chunk_by_source_body(body: str) -> list[Chunk]: index=index, label=label, indent_width=indent_width, + declared_indent_width=0, ) for index, (text, indent_width, label) in enumerate(_split_plain_text_units(body)) ] diff --git a/lineageweave/commitment_extraction.py b/lineageweave/commitment_extraction.py index db5f7761a..08292ebe6 100644 --- a/lineageweave/commitment_extraction.py +++ b/lineageweave/commitment_extraction.py @@ -168,5 +168,5 @@ def extract(self, post_title: str, post_body: str, reference_date: str) -> Custo content = body["choices"][0]["message"]["content"] commitment = parse_commitment_response(content) if commitment is None: - raise ValueError(f"commitment response did not match the required format: {content!r}") + raise ValueError("commitment response did not match the required format") return commitment diff --git a/lineageweave/image_content.py b/lineageweave/image_content.py index 3aa2c9038..48f11bcc7 100644 --- a/lineageweave/image_content.py +++ b/lineageweave/image_content.py @@ -84,6 +84,8 @@ def regions_cover_image(regions: tuple[ImageRegion, ...] | list[ImageRegion]) -> """ if not regions: return False + if len(regions) == 1 and regions[0] == ImageRegion(0.0, 0.0, 1.0, 1.0): + return False sample_count = 32 points = range(sample_count + 1) return all( @@ -180,7 +182,9 @@ def describe(self, image_bytes: bytes, mime_type: str) -> ImageDescription: # p "If the image contains a table, preserve its row/column structure: one row " "per line, with ' | ' between that row's cell values, in reading order -- " "never flatten a table into an unstructured word list.>\n" - "CAPTION: \n" + "CAPTION: <2-4 concise, evidence-grounded sentences describing the visible layout, " + "objects, relationships, directions, measurements, and labels; do not guess " + "anything that is not visible>\n" "TAGS: " ) _REGION_RESPONSE_FORMAT = ( @@ -257,9 +261,7 @@ def _parse_description(content: str) -> ImageDescription: fields["TEXT"].append(_strip_outer_markdown_emphasis(line)) if not fields["TEXT"] and not fields["CAPTION"]: - raise ImageDescriptionParseError( - f"vision response had neither TEXT nor CAPTION content: {content!r}" - ) + raise ImageDescriptionParseError("vision response had no usable TEXT or CAPTION content") extracted_text = "\n".join(fields["TEXT"]).strip() if extracted_text.upper() == "NONE": diff --git a/lineageweave/ontology.py b/lineageweave/ontology.py index 554623123..f7edf76e6 100644 --- a/lineageweave/ontology.py +++ b/lineageweave/ontology.py @@ -1,21 +1,19 @@ -"""Load the versioned LineageWeave Knowledge Graph ontology. - -``docs/ontology/lineageweave-kg.ttl`` is the formal OWL 2, RDFS, SKOS, -W3C Organization Ontology, and PROV-O vocabulary for navigation node and -edge types plus the controlled vocabularies backed by -``common_lookup_value``. Real corporate entities use W3C ORG; SKOS is -reserved for classifications and labels such as Group, Company, and Plant. - -PostgreSQL remains the source of record for graph data. This module is the -single application boundary for resolving a stored lookup code to its -canonical ontology IRI. The companion -``docs/ontology/lineageweave-kg.shacl.ttl`` publishes closed-world RDF -cardinality constraints for external consumers; database constraints and -RBAC/ABAC remain authoritative for product storage and disclosure. - -``tests/test_ontology.py`` checks lookup-code round trips, while -``tests/test_ontology_interoperability.py`` checks the ORG/SKOS separation, -version/import metadata, and SHACL contract. +"""Loads `docs/ontology/lineageweave-kg.ttl` -- the formal OWL 2 / RDFS / +SKOS vocabulary (ADR 0004) for `knowledge_graph_edge`'s node/edge types +and the `entity_relationship_type` / `person_side` / `corporate_entity_level` +controlled vocabularies in `migrations/0001_initial_schema.sql`. + +PostgreSQL stays the source of record for actual graph data; this module +is the single place application code gets a canonical IRI for a +`common_lookup_value.lookup_code`, instead of re-typing the lookup code +as a bare string wherever the ontology's vocabulary matters. The Turtle +file itself is the semantic-layer artifact -- see the ADR for why that +is the correct, standards-grounded reading of "semantic layer" here +rather than a separate BI-metrics concept. + +`tests/test_ontology.py` is the real correctness check: it loads the +same file with `rdflib` and asserts every lookup code the relational +schema actually defines has a matching ontology term, and vice versa. """ from __future__ import annotations @@ -38,12 +36,11 @@ def load_ontology() -> Graph: - """Parse the committed core Turtle ontology into a fresh RDF graph. - - External ``owl:imports`` are metadata only. ``rdflib`` parses the local - committed artifact and this function performs no network dereference. - Callers that need repeated access should use the module-level - :data:`ONTOLOGY` singleton or cache the returned graph. + """Parses `docs/ontology/lineageweave-kg.ttl` fresh. Callers that + need it repeatedly should cache the result themselves (see + `ONTOLOGY` below for the module-level singleton); this function + exists separately so tests can load a fresh graph without relying + on import-time caching. """ graph = Graph() graph.parse(_ONTOLOGY_PATH, format="turtle") @@ -56,7 +53,7 @@ def load_ontology() -> Graph: def _term_subject(lookup_code: str) -> Identifier | None: - """Return the ontology term annotated with ``lookup_code``, if present.""" + """Implement the _term_subject operation for this channel.""" for subject in ONTOLOGY.subjects(LOOKUP_CODE, None): if str(ONTOLOGY.value(subject, LOOKUP_CODE)) == lookup_code: return subject @@ -64,21 +61,22 @@ def _term_subject(lookup_code: str) -> Identifier | None: def iri_for_lookup_code(lookup_code: str) -> str | None: - """Return the canonical ontology IRI for one relational lookup code. - - ``None`` means the ontology deliberately does not cover that code, for - example a workflow status vocabulary outside this semantic profile. + """The ontology term IRI whose `:lookupCode` annotation equals + `lookup_code`, or `None` if no term declares that code -- e.g. a + `common_lookup_value` category this ontology doesn't cover yet + (`ticket_status`, `post_visibility`), which is a real, expected gap, + not a bug. """ subject = _term_subject(lookup_code) return str(subject) if subject is not None else None def ontology_annotations(lookup_code: str) -> dict[str, str]: - """Return the IRI and label for a declared lookup code. + """IRI + ``rdfs:label`` for a lookup code, or empty if undeclared. - An undeclared code returns an empty mapping rather than a fabricated - semantic label, preserving the product's missing-versus-negative - distinction. + Empty (not a fabricated label) when the ontology does not cover + this code -- the same missing-vs-negative discipline as Null + channels. Callers spread this onto an API payload. """ subject = _term_subject(lookup_code) if subject is None: @@ -91,7 +89,10 @@ def ontology_annotations(lookup_code: str) -> dict[str, str]: def all_declared_lookup_codes() -> set[str]: - """Return every relational lookup code declared by the ontology.""" + """Every `common_lookup_value.lookup_code` string this ontology + declares a term for, across all categories -- used by + `tests/test_ontology.py` to round-trip against the live schema. + """ return {str(value) for value in ONTOLOGY.objects(None, LOOKUP_CODE)} diff --git a/lineageweave/post_chat.py b/lineageweave/post_chat.py index ec3e23a73..0ad85f59c 100644 --- a/lineageweave/post_chat.py +++ b/lineageweave/post_chat.py @@ -369,7 +369,7 @@ def answer( content = body["choices"][0]["message"]["content"] answer = _parse_plain_chat_response(content, sources) if answer is None: - raise ValueError(f"chat response did not match the required format: {content!r}") + raise ValueError("chat response did not match the required format") return answer def compress_context( diff --git a/lineageweave/post_content_normalization.py b/lineageweave/post_content_normalization.py index ac2b4b89b..196b7e7b4 100644 --- a/lineageweave/post_content_normalization.py +++ b/lineageweave/post_content_normalization.py @@ -21,9 +21,7 @@ from __future__ import annotations -from concurrent.futures import ThreadPoolExecutor -from contextvars import Context, copy_context -from itertools import repeat +import math import re from dataclasses import dataclass, field @@ -69,7 +67,7 @@ class ImageContentResult: mime_type: str status_code: str description: ImageDescription | None = None - regions: tuple["ImageRegionResult", ...] = field(default_factory=tuple) + regions: tuple[ImageRegionResult, ...] = field(default_factory=tuple) @dataclass(frozen=True) @@ -121,7 +119,7 @@ def _image_placeholder(description: ImageDescription) -> str: caption = description.caption or "no caption available" ocr = description.extracted_text.strip() if ocr: - return f"[image: {caption} | text: {ocr}]" + return f"[image: {caption}]\n\n{ocr}\n" return f"[image: {caption}]" @@ -133,6 +131,24 @@ def _merge_region_descriptions(descriptions: list[ImageDescription]) -> ImageDes return ImageDescription(extracted_text=extracted_text, caption=captions, tags=tags) +def _is_bounded_region(region: ImageRegion) -> bool: + """Accept only finite, positive regions wholly inside the image.""" + if not isinstance(region, ImageRegion): + return False + values = (region.x, region.y, region.width, region.height) + if not all(isinstance(value, (int, float)) for value in values): + return False + return ( + all(math.isfinite(value) for value in values) + and 0.0 <= region.x <= 1.0 + and 0.0 <= region.y <= 1.0 + and 0.0 < region.width <= 1.0 + and 0.0 < region.height <= 1.0 + and region.x + region.width <= 1.0 + and region.y + region.height <= 1.0 + ) + + def _describe_image_region( region_index: int, image_bytes: bytes, @@ -149,25 +165,6 @@ def _describe_image_region( return ImageRegionResult(region_index, region, "described", description) -def _describe_image_region_in_context( - context: Context, - region_index: int, - image_bytes: bytes, - mime_type: str, - region: ImageRegion, - vision_client: ImageContentClient, -) -> ImageRegionResult: - """Run a region task with the post's metadata context attached.""" - return context.run( - _describe_image_region, - region_index, - image_bytes, - mime_type, - region, - vision_client, - ) - - def _describe_image_chunk( chunk: Chunk, vision_client: ImageContentClient ) -> tuple[ImageContentResult, ImageDescription | None, str]: @@ -187,32 +184,53 @@ def _describe_image_chunk( regions = locator(chunk.image_data, chunk.label) if callable(locator) else () except Exception: # noqa: BLE001 - locator failure falls back to whole-image evidence. regions = () - if not regions_cover_image(regions): - # A provider may return only a salient crop even when the contract asks for - # full-image coverage. Preserve the missing evidence with one bounded region. - regions = (ImageRegion(0.0, 0.0, 1.0, 1.0),) - # Keep each region's request bounded and preserve LLM metadata while avoiding - # serial timeout multiplication for image panels. - with ThreadPoolExecutor(max_workers=min(8, len(regions))) as executor: + try: + regions = tuple( + region + for region in (regions or ()) + if _is_bounded_region(region) + ) + except Exception: # noqa: BLE001 - malformed locator output falls back safely. + regions = () + full_image_region = len(regions) == 1 and regions[0] == ImageRegion(0.0, 0.0, 1.0, 1.0) + partial_regions = bool(regions) and not full_image_region and not regions_cover_image(regions) + if not regions or full_image_region: + # Missing or full-image locator output is not a decomposed region. + # Preserve parent-image evidence below without inventing coordinates. + regions = () + # ponytail: serialize per-post VISION calls; nested image/region pools + # overwhelmed the gateway and turned valid region evidence into failures. + # Reintroduce bounded concurrency only after provider capacity is measured. + if regions: region_results.extend( - executor.map( - _describe_image_region_in_context, - (copy_context() for _ in regions), - range(len(regions)), - repeat(chunk.image_data), - repeat(chunk.label), - regions, - repeat(vision_client), + _describe_image_region( + region_index, + chunk.image_data, + chunk.label, + region, + vision_client, ) + for region_index, region in enumerate(regions) ) successful_regions = [ item.description for item in region_results if item.description is not None ] - description = ( - _merge_region_descriptions(successful_regions) - if successful_regions - else vision_client.describe(chunk.image_data, chunk.label) - ) + if partial_regions: + # Keep valid salient panels instead of replacing them with a full-image + # crop, then ask once more for the uncovered parent image so text outside + # those panels remains searchable and its original location is preserved. + try: + description = vision_client.describe(chunk.image_data, chunk.label) + except Exception: + if not successful_regions: + raise + description = _merge_region_descriptions(successful_regions) + else: + description = ( + _merge_region_descriptions(successful_regions) + if successful_regions + else vision_client.describe(chunk.image_data, chunk.label) + ) except Exception: # noqa: BLE001 - a provider failure must not drop the whole post. return ImageContentResult(chunk.index, chunk.label, "failed"), None, "[image: content unavailable]" @@ -226,15 +244,6 @@ def _describe_image_chunk( return result, description, _image_placeholder(description) -def _describe_image_chunk_in_context( - context: Context, - chunk: Chunk, - vision_client: ImageContentClient, -) -> tuple[ImageContentResult, ImageDescription | None, str]: - """Run one parallel vision task with the caller's request context.""" - return context.run(_describe_image_chunk, chunk, vision_client) - - def normalize_post_body( body: str, vision_client: ImageContentClient | None = None ) -> NormalizedPostContent: @@ -263,20 +272,8 @@ def normalize_post_body( image_outcomes: dict[int, tuple[ImageContentResult, ImageDescription | None, str]] = {} image_chunks = [chunk for chunk in chunks if chunk.unit_type == "image"] if image_chunks and vision_client.available: - # ponytail: cap independent provider calls at eight; raise only with measured throughput need. - with ThreadPoolExecutor(max_workers=min(8, len(image_chunks))) as executor: - image_outcomes.update( - zip( - (chunk.index for chunk in image_chunks), - executor.map( - _describe_image_chunk_in_context, - (copy_context() for _ in image_chunks), - image_chunks, - repeat(vision_client), - ), - strict=True, - ) - ) + for chunk in image_chunks: + image_outcomes[chunk.index] = _describe_image_chunk(chunk, vision_client) for chunk in chunks: if chunk.unit_type == "dom": diff --git a/lineageweave/post_content_persistence.py b/lineageweave/post_content_persistence.py index 4df7597b0..86dcfd4de 100644 --- a/lineageweave/post_content_persistence.py +++ b/lineageweave/post_content_persistence.py @@ -9,6 +9,7 @@ import asyncio import hashlib +import logging import math from typing import Any, TypeVar @@ -16,18 +17,25 @@ from .embedding_client import EmbeddingClient from .image_content import ImageContentClient, ImageDescription from .post_content_normalization import ImageContentResult, normalize_post_body -from .post_structure import NullPostStructureClient, PostStructureClient, StructureDecision +from .post_structure import ( + NullPostStructureClient, + PostStructureClient, + StructureDecision, +) _LLM_BATCH_MAX_UNITS = 32 _LLM_BATCH_MAX_CHARS = 24_000 _STRUCTURE_UNIT_MAX_CHARS = 8_000 _BatchKey = TypeVar("_BatchKey") +_LOGGER = logging.getLogger(__name__) -def _bounded_unit_batches(units: list[tuple[_BatchKey, str]]) -> list[list[tuple[_BatchKey, str]]]: +def _bounded_unit_batches( # noqa: UP047 - retain Python 3.10 compatibility. + units: list[tuple[_BatchKey, str]], +) -> list[list[tuple[_BatchKey, str]]]: """Keep provider requests bounded without changing persisted source units.""" - batches: list[list[tuple[int, str]]] = [] - batch: list[tuple[int, str]] = [] + batches: list[list[tuple[_BatchKey, str]]] = [] + batch: list[tuple[_BatchKey, str]] = [] batch_chars = 0 for unit in units: unit_chars = len(unit[1]) @@ -111,21 +119,29 @@ async def persist_post_content( if chunk.unit_type != "image" and unit_text ] explicit_widths = sorted( - {int(chunk.indent_width) for chunk in text_chunks if int(chunk.indent_width) > 0} + { + int(chunk.declared_indent_width) + for chunk in text_chunks + if int(chunk.declared_indent_width) > 0 + } ) # CSS/XML indentation values are presentation widths, not semantic depth. - # Rank the observed widths instead of dividing by a gcd: 56px and 80px - # are two nesting levels even when their pixel-unit gcd is 1. + # Rank declared widths instead of dividing by a gcd: 56px and 80px are two + # nesting levels even when their pixel-unit gcd is 1. Leading source + # whitespace is deliberately excluded: editors use it for visual alignment + # and it is not authoritative hierarchy without an orchestrator decision. explicit_levels = {width: level for level, width in enumerate(explicit_widths, start=1)} - unresolved = [chunk for chunk in text_chunks if int(chunk.indent_width) <= 0] + unresolved = [ + chunk for chunk in text_chunks if int(chunk.declared_indent_width) <= 0 + ] unresolved_indexes = {chunk.index for chunk in unresolved} structure_by_index: dict[int, StructureDecision] = {} for chunk in text_chunks: - width = int(chunk.indent_width) + width = int(chunk.declared_indent_width) if width > 0: structure_by_index[chunk.index] = StructureDecision( unit_index=chunk.index, - indent_level=explicit_levels[width], + indent_level=explicit_levels[width], confidence=1.0, evidence="Explicit HTML, CSS, or OOXML indentation.", source_code="explicit", @@ -154,8 +170,15 @@ async def persist_post_content( for decision in decisions: if decision.unit_index in unresolved_indexes: structure_by_index[decision.unit_index] = decision - except Exception: # noqa: BLE001 - failed batches remain unresolved for retry. - continue + except (OSError, RuntimeError, ValueError) as exc: + _LOGGER.warning( + "post content structure batch unavailable", + extra={ + "post_id": post_id, + "batch_size": len(batch), + "exception_type": type(exc).__name__, + }, + ) for chunk in unresolved: structure_by_index.setdefault( chunk.index, @@ -192,8 +215,15 @@ async def persist_post_content( for value in vector ): vectors[embedding_key] = [float(value) for value in vector] - except Exception: # noqa: BLE001 - failed batches remain absent for retry. - continue + except (OSError, RuntimeError, ValueError) as exc: + _LOGGER.warning( + "post content embedding batch unavailable", + extra={ + "post_id": post_id, + "batch_size": len(batch), + "exception_type": type(exc).__name__, + }, + ) async with conn.transaction(): await conn.execute("delete from post_content_unit where post_id = $1", post_id) diff --git a/lineageweave/post_summary.py b/lineageweave/post_summary.py index 29d5efc51..7301a1c73 100644 --- a/lineageweave/post_summary.py +++ b/lineageweave/post_summary.py @@ -59,7 +59,7 @@ FIVE_W1H_EVIDENCE_SLOTS = frozenset({"when", "where", "why", "how"}) # Stored rows without this contract version are legacy summaries and must be # regenerated from the current source body before the popup treats them as evidence. -POST_SUMMARY_CONTRACT_VERSION = 5 +POST_SUMMARY_CONTRACT_VERSION = 6 @dataclass(frozen=True) @@ -109,6 +109,7 @@ class MajorEventAction: requester_actor_name: str | None processor_actor_name: str | None evidence_text: str + project_key: str | None = None def __post_init__(self) -> None: if not self.action_text.strip() or not self.evidence_text.strip(): @@ -116,6 +117,7 @@ def __post_init__(self) -> None: for field_name, value in ( ("requester_actor_name", self.requester_actor_name), ("processor_actor_name", self.processor_actor_name), + ("project_key", self.project_key), ): if value is not None and not value.strip(): raise ValueError(f"{field_name} must be non-empty when provided") @@ -137,6 +139,20 @@ def __post_init__(self) -> None: raise ValueError("project mention confidence must be between 0 and 1") +@dataclass(frozen=True) +class KeyEvent: + """One source-grounded event, optionally bound to a named project.""" + + event_text: str + project_key: str | None = None + + def __post_init__(self) -> None: + if not self.event_text.strip(): + raise ValueError("key events require event text") + if self.project_key is not None and not self.project_key.strip(): + raise ValueError("project_key must be non-empty when provided") + + @dataclass(frozen=True) class FiveW1HEvidence: """One explicitly stated 5W1H value and its supporting source phrase.""" @@ -158,12 +174,23 @@ def normalize_project_key(project_name: str) -> str: return re.sub(r"[^\w]+", "-", normalized, flags=re.UNICODE).strip("-") +def _parse_optional_project_key(value: object) -> str | None: + """Normalize an explicitly named project, rejecting empty sentinel values.""" + if not isinstance(value, str) or not value.strip(): + return None + project_key = normalize_project_key(value) + if project_key in {"", "none", "null", "unknown", "n-a", "na"}: + return None + return project_key + + @dataclass(frozen=True) class PostSummary: """The popup summary panel's full content for one post.""" korean_summary: str key_events: tuple[str, ...] = field(default_factory=tuple) + key_event_details: tuple[KeyEvent, ...] = field(default_factory=tuple) roles_and_responsibilities: tuple[RoleResponsibility, ...] = field(default_factory=tuple) major_event_actions: tuple[MajorEventAction, ...] = field(default_factory=tuple) project_mentions: tuple[ProjectMention, ...] = field(default_factory=tuple) @@ -203,7 +230,7 @@ def summarize_with_hints( _SUMMARY_PROMPT_TEMPLATE = """\ Read the post below (it may be in English, Korean, or mixed) and produce -four things: +five things: Do not output a reasoning trace. Return the JSON object immediately. @@ -226,6 +253,7 @@ def summarize_with_hints( do not force a team's name into an organization slot: a team is a sub-unit of a company, not the company itself -- decide which of the three each actor is, and say which. + Name every person and organization by their actual stated name whenever the text gives one (e.g. "홍길동 PM, 김철수 PM이 참석했다" instead of a collective "PM들이 참석했다"). When the actor is a person and the text names or clearly implies who they work for, also give that organization's name -- a bare person name without their employer is hard to place. When the actor is a @@ -242,6 +270,8 @@ def summarize_with_hints( topic. Keep ambiguous candidates with confidence below 0.7 so the UI can show uncertainty, but they must not be used as a report grouping. +5. A list of 5W1H evidence items. Explicitly extract specific 'when', 'where', 'why', and 'how' facts from the text. For each fact, return the slot code, the extracted value, and the exact supporting phrase. Do not infer anything not in the text. + Structured context hints (hints, not proof): {context_hints} Treat a customer value such as 기타, 미등록고객, unknown, or other as a weak hint; it cannot confirm a project by itself. @@ -253,7 +283,9 @@ def summarize_with_hints( Reply with ONLY a JSON object (no markdown fences, no prose) with exactly these fields: "korean_summary": string - "key_events": array of strings + "key_events": array of objects, each with: + "event_text": string, + "project_name": string (the name of the project this event belongs to, or null if unassigned) "roles_and_responsibilities": array of objects, each with: "actor_name": string "responsibility": string @@ -326,9 +358,11 @@ def summarize_with_hints( 설계팀이 다음 주까지 수정 도면을 제출하기로 했다. Then write a new line beginning KEY EVENTS: followed by up to four short -event phrases separated by semicolons. If the evidence covers multiple -distinct matters, include events from each of them, not only the first -or most prominent one. If there are no events, write NONE. +event phrases separated by semicolons. When a project or matter is named, +write each event as `project canonical key :: event phrase`; use `NONE ::` +only when the event is not attributable to a named project. If the evidence +covers multiple distinct matters, include events from each of them, not only +the first or most prominent one. If there are no events, write NONE. Context hints are weak evidence only: {context_hints} Post title: {title} Post body: {body} @@ -367,15 +401,16 @@ def summarize_with_hints( project name | canonical name | shortest supporting evidence | confidence from 0 to 1 ACTIONS: -major event or action | requester actor name or NONE | processor actor name or NONE | shortest supporting evidence +major event or action | project canonical key or NONE | requester actor name or NONE | processor actor name or NONE | shortest supporting evidence EVIDENCE: slot (when, where, why, or how) | value stated in the post | shortest supporting phrase Use NONE on the line after a marker when the evidence supports no item. Keep -each row short. For ACTIONS, requester and processor must be actor names also -present in ROLES. Use NONE only when the post does not name that actor. Do not -invent actors, projects, affiliations, actions, or confidence. +each row short. For ACTIONS, the project canonical key must exactly match a +canonical name in PROJECTS or be NONE. Requester and processor must be actor +names also present in ROLES. Use NONE only when the post does not name that +actor. Do not invent actors, projects, affiliations, actions, or confidence. Only write EVIDENCE rows when the post explicitly supports the value; do not turn the record's filing timestamp into an event time and do not infer a place, reason, or method from a title alone. @@ -399,7 +434,9 @@ def _strip_code_fence(content: str) -> str: return match.group(1) if match else content -def _parse_plain_summary_response(content: str) -> tuple[str, tuple[str, ...]] | None: +def _parse_plain_summary_response( + content: str, +) -> tuple[str, tuple[str, ...], tuple[KeyEvent, ...]] | None: """Parse the provider-compatible plain summary and event marker.""" plain = _strip_code_fence(content).strip() match = re.search(r"(?im)^\s*KEY EVENTS\s*:\s*", plain) @@ -407,12 +444,24 @@ def _parse_plain_summary_response(content: str) -> tuple[str, tuple[str, ...]] | return (plain, ()) if plain else None summary = plain[: match.start()].strip() raw_events = plain[match.end() :].strip() - events = tuple( - event.strip(" -*\t") - for event in re.split(r";|\n", raw_events) - if event.strip(" -*\t") and event.strip(" -*\t").upper() != "NONE" - ) - return (summary, events) if summary else None + events: list[str] = [] + details: list[KeyEvent] = [] + for raw_event in re.split(r";|\n", raw_events): + event = raw_event.strip(" -*\t") + if not event or event.upper() == "NONE": + continue + if "::" in event: + project_raw, event_text = (part.strip() for part in event.split("::", 1)) + event_text = event_text.strip(" -*\t") + project_key = _parse_optional_project_key(project_raw) + else: + event_text = event + project_key = None + if not event_text or event_text.upper() == "NONE": + continue + events.append(event_text) + details.append(KeyEvent(event_text=event_text, project_key=project_key)) + return (summary, tuple(events), tuple(details)) if summary else None _HINT_VALUE_PATTERN = re.compile(r"(?:^|;)\s*author_account_name=([^;\[]+?)\s*(?:\[|;|$)") @@ -560,15 +609,29 @@ def _parse_plain_summary_details( except (TypeError, ValueError): continue actions: list[MajorEventAction] = [] + role_names = {role.actor_name.casefold() for role in roles} + + def _is_actor_field(value: str) -> bool: + """Recognize the actor columns required by the five-column contract.""" + return value.casefold() in empty_values or value.casefold() in role_names + for raw_row in sections.get("ACTIONS", "").splitlines(): row = raw_row.strip().lstrip("-* ").strip() if not row or row.casefold() in empty_values: continue - parts = [part.strip() for part in row.split("|", 3)] - if len(parts) != 4: + parts = [part.strip() for part in row.split("|", 4)] + if len(parts) == 5 and _is_actor_field(parts[2]) and _is_actor_field(parts[3]): + action_text, project_key_raw, requester, processor, evidence_text = parts + project_key = _parse_optional_project_key(project_key_raw) + else: + legacy_parts = [part.strip() for part in row.split("|", 3)] + if len(legacy_parts) != 4: + continue + action_text, requester, processor, evidence_text = legacy_parts + project_key = None + if not action_text: continue - action_text, requester, processor, evidence_text = parts - if not action_text or evidence_text.casefold() in empty_values: + if evidence_text.casefold() in empty_values: continue requester_name = None if requester.casefold() in empty_values else requester processor_name = None if processor.casefold() in empty_values else processor @@ -579,6 +642,7 @@ def _parse_plain_summary_details( requester_actor_name=requester_name, processor_actor_name=processor_name, evidence_text=evidence_text, + project_key=project_key, ) ) except ValueError: @@ -619,9 +683,29 @@ def parse_summary_response(content: str) -> PostSummary | None: return None key_events_raw = parsed.get("key_events") or [] - key_events = tuple(e.strip() for e in key_events_raw if isinstance(e, str) and e.strip()) if isinstance( - key_events_raw, list - ) else () + key_events: list[str] = [] + key_event_details: list[KeyEvent] = [] + if isinstance(key_events_raw, list): + for entry in key_events_raw: + if isinstance(entry, str) and entry.strip(): + parsed_event = _parse_plain_summary_response(f"summary\nKEY EVENTS: {entry}") + if parsed_event is not None: + _summary, events, details = parsed_event + key_events.extend(events) + key_event_details.extend(details) + elif isinstance(entry, dict): + event_text = entry.get("event_text") or entry.get("event") + if not isinstance(event_text, str) or not event_text.strip(): + continue + project_key = _parse_optional_project_key( + entry.get("project_key") or entry.get("project_name") + ) + try: + detail = KeyEvent(event_text=event_text.strip(), project_key=project_key) + except ValueError: + continue + key_events.append(detail.event_text) + key_event_details.append(detail) rr_raw = parsed.get("roles_and_responsibilities") or [] roles: list[RoleResponsibility] = [] @@ -703,6 +787,9 @@ def parse_summary_response(content: str) -> PostSummary | None: evidence_text = entry.get("evidence_text") or entry.get("evidence") requester = entry.get("requester_actor_name") processor = entry.get("processor_actor_name") + project_key = _parse_optional_project_key( + entry.get("project_key") or entry.get("project_name") + ) if not isinstance(action_text, str) or not isinstance(evidence_text, str): continue requester_name = requester.strip() if isinstance(requester, str) and requester.strip() else None @@ -714,6 +801,7 @@ def parse_summary_response(content: str) -> PostSummary | None: requester_actor_name=requester_name, processor_actor_name=processor_name, evidence_text=evidence_text.strip(), + project_key=project_key, ) ) except ValueError: @@ -738,7 +826,8 @@ def parse_summary_response(content: str) -> PostSummary | None: return PostSummary( korean_summary=korean_summary.strip(), - key_events=key_events, + key_events=tuple(key_events), + key_event_details=tuple(key_event_details), roles_and_responsibilities=tuple(roles), major_event_actions=tuple(actions), project_mentions=tuple(project_mentions), @@ -867,8 +956,8 @@ def summarize_with_hints( content = body["choices"][0]["message"]["content"] parsed = _parse_plain_summary_response(content) if parsed is None: - raise ValueError(f"summary response did not match the required format: {content!r}") - korean_summary, key_events = parsed + raise ValueError("summary response did not match the required format") + korean_summary, key_events, key_event_details = parsed details_body = post_json( f"{self._base_url}/v1/chat/completions", { @@ -903,6 +992,7 @@ def summarize_with_hints( return PostSummary( korean_summary=korean_summary, key_events=key_events, + key_event_details=key_event_details, roles_and_responsibilities=roles, major_event_actions=actions, project_mentions=projects, diff --git a/lineageweave/rankweave_client.py b/lineageweave/rankweave_client.py index eb0b3358b..1e5f77eb2 100644 --- a/lineageweave/rankweave_client.py +++ b/lineageweave/rankweave_client.py @@ -224,11 +224,11 @@ def __call__( ) except Exception as exc: raise RankWeaveNotAvailable( - f"rankweave_not_available: weighted_reciprocal_rank_fuse failed ({exc})" + "rankweave_not_available: weighted_reciprocal_rank_fuse failed" ) from exc except Exception as exc: raise RankWeaveNotAvailable( - f"rankweave_not_available: weighted_reciprocal_rank_fuse failed ({exc})" + "rankweave_not_available: weighted_reciprocal_rank_fuse failed" ) from exc projected: list[dict[str, Any]] = [] for hit in hits: @@ -268,7 +268,7 @@ def fuse_rankings( raise except Exception as exc: raise RankWeaveNotAvailable( - f"rankweave_not_available: ranking transport failed ({exc})" + "rankweave_not_available: ranking transport failed" ) from exc return project_ranking_list(raw, titles_by_id) diff --git a/lineageweave/semantic_hints.py b/lineageweave/semantic_hints.py index 6a9fa0b0f..d594eb825 100644 --- a/lineageweave/semantic_hints.py +++ b/lineageweave/semantic_hints.py @@ -48,12 +48,15 @@ def format_semantic_hints( source_author_name: str | None = None, source_company_code: str | None = None, source_company_name: str | None = None, + source_company_catalog_name: str | None = None, source_business_unit_code: str | None = None, source_process_unit_name: str | None = None, + source_process_unit_catalog_name: str | None = None, source_sales_pool_code: str | None = None, source_sales_pool_name: str | None = None, source_customer_code: str | None = None, source_customer_name: str | None = None, + source_customer_catalog_name: str | None = None, source_project_code: str | None = None, source_project_name: str | None = None, source_context_present: bool = False, @@ -128,6 +131,33 @@ def format_semantic_hints( if source_customer_name_value == "none" else customer_hint_trust(source_customer_code, source_customer_name) ) + catalog_hints = [ + f"{label}={_value(name)} [source_lookup={lookup_table}.{lookup_column}]" + for label, code, name, lookup_table, lookup_column in ( + ( + "source_company_catalog_name", + source_company_code, + source_company_catalog_name, + "corporate_entity", + "corporate_entity_code", + ), + ( + "source_process_unit_catalog_name", + source_business_unit_code, + source_process_unit_catalog_name, + "process_unit", + "process_unit_code", + ), + ( + "source_customer_catalog_name", + source_customer_code, + source_customer_catalog_name, + "corporate_entity", + "corporate_entity_code", + ), + ) + if code is not None and str(code).strip() + ] return "; ".join( ( f"author_account_id={_value(account_id)} [source_field=source_post.author_account_id]", @@ -153,5 +183,6 @@ def format_semantic_hints( f"source_customer_name_hint_trust={source_customer_name_trust}", f"source_project_code={_value(source_project_code)} [source_field=source_post.source_project_code]", f"source_project_name={_value(source_project_name)} [source_field=source_post.source_project_name]", + *catalog_hints, ) ) diff --git a/migrations/0101_project_bound_major_event_action.sql b/migrations/0101_project_bound_major_event_action.sql new file mode 100644 index 000000000..f2a39b55c --- /dev/null +++ b/migrations/0101_project_bound_major_event_action.sql @@ -0,0 +1,18 @@ +alter table post_summary_action + add column if not exists project_key text; + +do $$ +begin + if not exists ( + select 1 + from pg_constraint + where conname = 'post_summary_action_project_mention_fk' + and conrelid = 'post_summary_action'::regclass + ) then + alter table post_summary_action + add constraint post_summary_action_project_mention_fk + foreign key (post_id, project_key) + references post_project_mention (post_id, project_key); + end if; +end +$$; diff --git a/migrations/0102_project_bound_summary_event.sql b/migrations/0102_project_bound_summary_event.sql new file mode 100644 index 000000000..c27721516 --- /dev/null +++ b/migrations/0102_project_bound_summary_event.sql @@ -0,0 +1,18 @@ +alter table post_summary_event + add column if not exists project_key text; + +do $$ +begin + if not exists ( + select 1 + from pg_constraint + where conname = 'post_summary_event_project_mention_fk' + and conrelid = 'post_summary_event'::regclass + ) then + alter table post_summary_event + add constraint post_summary_event_project_mention_fk + foreign key (post_id, project_key) + references post_project_mention (post_id, project_key); + end if; +end +$$; diff --git a/migrations/0103_tenant_settings.sql b/migrations/0103_tenant_settings.sql new file mode 100644 index 000000000..9470ebe9a --- /dev/null +++ b/migrations/0103_tenant_settings.sql @@ -0,0 +1,6 @@ +CREATE TABLE tenant_settings ( + id int PRIMARY KEY CHECK (id = 1), + brand_name text NOT NULL DEFAULT 'LineageWeave', + updated_at timestamptz NOT NULL DEFAULT now() +); +INSERT INTO tenant_settings (id, brand_name) VALUES (1, 'LineageWeave'); diff --git a/migrations/rollback/0103_tenant_settings.sql b/migrations/rollback/0103_tenant_settings.sql new file mode 100644 index 000000000..c1cb3fe51 --- /dev/null +++ b/migrations/rollback/0103_tenant_settings.sql @@ -0,0 +1 @@ +DROP TABLE tenant_settings; diff --git a/patch_api.py b/patch_api.py new file mode 100644 index 000000000..250e265e7 --- /dev/null +++ b/patch_api.py @@ -0,0 +1,35 @@ +with open("frontend/src/api.ts", "r") as f: + content = f.read() + +new_api = """ +export async function fetchTenantConfig(accessToken: string): Promise<{ brandName: string }> { + const response = await fetch(`${config.backendBaseUrl}/api/settings`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (!response.ok) { + throw new Error(`Failed to fetch tenant config: ${response.status}`); + } + return response.json(); +} + +export async function updateTenantConfig(accessToken: string, brandName: string): Promise<{ brandName: string }> { + const response = await fetch(`${config.backendBaseUrl}/api/settings`, { + method: "PATCH", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ brandName }), + }); + if (!response.ok) { + throw new Error(`Failed to update tenant config: ${response.status}`); + } + return response.json(); +} +""" + +if "fetchTenantConfig" not in content: + content += new_api + with open("frontend/src/api.ts", "w") as f: + f.write(content) + print("Patched api.ts") diff --git a/patch_app_fetch.py b/patch_app_fetch.py new file mode 100644 index 000000000..57320ef8c --- /dev/null +++ b/patch_app_fetch.py @@ -0,0 +1,24 @@ +with open("frontend/src/App.tsx", "r") as f: + content = f.read() + +# Add imports for fetchTenantConfig +content = content.replace( + '} from "./api";', + ' fetchTenantConfig,\n} from "./api";' +) + +# Replace standard state with fetch hook inside App +old_state = ' const [brandName, setBrandName] = useState("LineageWeave");' +new_state = """ const [brandName, setBrandName] = useState("LineageWeave"); + useEffect(() => { + if (accessToken) { + fetchTenantConfig(accessToken).then((config) => { + if (config.brandName) setBrandName(config.brandName); + }).catch(console.error); + } + }, [accessToken]);""" + +content = content.replace(old_state, new_state) + +with open("frontend/src/App.tsx", "w") as f: + f.write(content) diff --git a/patch_app_order.py b/patch_app_order.py new file mode 100644 index 000000000..07f4ffc32 --- /dev/null +++ b/patch_app_order.py @@ -0,0 +1,36 @@ +with open("frontend/src/App.tsx", "r") as f: + content = f.read() + +# We have: +# const [brandName, setBrandName] = useState("LineageWeave"); +# useEffect(() => { ... }, [accessToken]); +# const auth = useAuth(); +# const [destination, setDestination] = useState("board"); +# ... +# const testOnlyLabPanels = import.meta.env.MODE === "test" && showLabPanels; +# const accessToken = auth.user?.access_token; + +# We need to move the useEffect down after accessToken is defined. + +import re + +# Remove the bad useEffect +bad_effect_pattern = r" useEffect\(\(\) => \{\n if \(accessToken\) \{\n fetchTenantConfig\(accessToken\).then\(\(config\) => \{\n if \(config\.brandName\) setBrandName\(config\.brandName\);\n \}\)\.catch\(console\.error\);\n \}\n \}, \[accessToken\]\);\n" +content = re.sub(bad_effect_pattern, "", content) + +# Insert it after accessToken is defined +access_token_line = ' const accessToken = auth.user?.access_token;\n' +good_effect = """ + useEffect(() => { + if (accessToken) { + fetchTenantConfig(accessToken).then((config) => { + if (config.brandName) setBrandName(config.brandName); + }).catch(console.error); + } + }, [accessToken]); +""" + +content = content.replace(access_token_line, access_token_line + good_effect) + +with open("frontend/src/App.tsx", "w") as f: + f.write(content) diff --git a/patch_app_test.py b/patch_app_test.py new file mode 100644 index 000000000..c053852da --- /dev/null +++ b/patch_app_test.py @@ -0,0 +1,15 @@ +import re + +with open("frontend/src/App.test.tsx", "r") as f: + content = f.read() + +target = """ if (url.endsWith("/api/me/preferences") && method === "PATCH") {""" +replacement = """ if (url.endsWith("/api/settings")) { + return Promise.resolve(jsonResponse({ brandName: "LineageWeave" })); + } + if (url.endsWith("/api/me/preferences") && method === "PATCH") {""" + +content = content.replace(target, replacement) + +with open("frontend/src/App.test.tsx", "w") as f: + f.write(content) diff --git a/patch_main.py b/patch_main.py new file mode 100644 index 000000000..dc990d319 --- /dev/null +++ b/patch_main.py @@ -0,0 +1,41 @@ +import re + +with open("backend/app/main.py", "r") as f: + content = f.read() + +endpoints = """ +@app.get("/api/settings", response_model=dict) +async def read_tenant_settings( + account: CurrentAccount, + conn: asyncpg.Connection = Depends(get_db), +): + row = await conn.fetchrow("SELECT brand_name FROM tenant_settings WHERE id = 1") + if not row: + return {"brandName": "LineageWeave"} + return {"brandName": row["brand_name"]} + +@app.patch("/api/settings", response_model=dict) +async def update_tenant_settings( + payload: dict, + account: CurrentAccount, + conn: asyncpg.Connection = Depends(get_db), +): + # Only admins can change settings + _require_post_admin(account) + brand_name = payload.get("brandName", "LineageWeave") + await conn.execute( + "INSERT INTO tenant_settings (id, brand_name) VALUES (1, $1) " + "ON CONFLICT (id) DO UPDATE SET brand_name = $1", + brand_name + ) + return {"brandName": brand_name} +""" + +if "@app.get(\"/api/settings\"" not in content: + # Insert before the last function or at a logical place + content = content.replace("async def healthz", endpoints + "\n\nasync def healthz") + with open("backend/app/main.py", "w") as f: + f.write(content) + print("Patched main.py") +else: + print("Endpoints already exist") diff --git a/patch_main_pool.py b/patch_main_pool.py new file mode 100644 index 000000000..4f93ae807 --- /dev/null +++ b/patch_main_pool.py @@ -0,0 +1,66 @@ +import re + +with open("backend/app/main.py", "r") as f: + content = f.read() + +# Replace read_tenant_settings +old_read = """@app.get("/api/settings", response_model=dict) +async def read_tenant_settings( + account: CurrentAccount, + conn: asyncpg.Connection = Depends(get_db), +): + row = await conn.fetchrow("SELECT brand_name FROM tenant_settings WHERE id = 1") + if not row: + return {"brandName": "LineageWeave"} + return {"brandName": row["brand_name"]}""" + +new_read = """@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") + if not row: + return {"brandName": "LineageWeave"} + return {"brandName": row["brand_name"]}""" + +# Replace update_tenant_settings +old_update = """@app.patch("/api/settings", response_model=dict) +async def update_tenant_settings( + payload: dict, + account: CurrentAccount, + conn: asyncpg.Connection = Depends(get_db), +): + # Only admins can change settings + _require_post_admin(account) + brand_name = payload.get("brandName", "LineageWeave") + await conn.execute( + "INSERT INTO tenant_settings (id, brand_name) VALUES (1, $1) " + "ON CONFLICT (id) DO UPDATE SET brand_name = $1", + brand_name + ) + return {"brandName": brand_name}""" + +new_update = """@app.patch("/api/settings", response_model=dict) +async def update_tenant_settings( + payload: dict, + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +): + # Only admins can change settings + _require_post_admin(account) + 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", + brand_name + ) + return {"brandName": brand_name}""" + +content = content.replace(old_read, new_read) +content = content.replace(old_update, new_update) + +with open("backend/app/main.py", "w") as f: + f.write(content) diff --git a/scripts/backfill_post_content.py b/scripts/backfill_post_content.py index 11b7c0ab3..ef54c91a7 100644 --- a/scripts/backfill_post_content.py +++ b/scripts/backfill_post_content.py @@ -22,6 +22,7 @@ if str(REPOSITORY_ROOT) not in sys.path: sys.path.insert(0, str(REPOSITORY_ROOT)) +from backend.app.post_content_queue import record_post_content_backfill_success from lineageweave.embedding_client import NullEmbeddingClient, orchestrator_embedding_client from lineageweave.image_content import NullImageContentClient, orchestrator_vision_client from lineageweave.llm_context import build_post_llm_metadata, use_llm_metadata @@ -227,6 +228,12 @@ async def backfill_post_content( structure_client=structure_client, post_title=row["post_title"], ) + async with conn.transaction(): + await record_post_content_backfill_success( + conn, + str(row["post_id"]), + str(row["post_body"] or ""), + ) result["processed_posts"] += 1 if described_images: result["described_posts"] += 1 diff --git a/scripts/backfill_post_summaries.py b/scripts/backfill_post_summaries.py index 1998510f0..53cd214a0 100644 --- a/scripts/backfill_post_summaries.py +++ b/scripts/backfill_post_summaries.py @@ -81,12 +81,15 @@ def _semantic_hints(row: asyncpg.Record) -> str: source_author_name=source_author_name, source_company_code=row["source_company_code"], source_company_name=row["source_company_name"], + source_company_catalog_name=row["source_company_catalog_name"], source_business_unit_code=row["source_process_unit_code"], source_process_unit_name=row["source_process_unit_name"], + source_process_unit_catalog_name=row["source_process_unit_catalog_name"], source_sales_pool_code=row["source_sales_pool_code"], source_sales_pool_name=row["source_sales_pool_name"], source_customer_code=row["source_customer_code"], source_customer_name=row["source_customer_name"], + source_customer_catalog_name=row["source_customer_catalog_name"], source_project_code=row["source_project_code"], source_project_name=row["source_project_name"], ) @@ -110,12 +113,15 @@ async def _load_posts( post.source_author_name, post.source_company_code, post.source_company_name, + source_company.entity_name as source_company_catalog_name, post.source_process_unit_code, post.source_process_unit_name, + source_process_unit.process_unit_name as source_process_unit_catalog_name, post.source_sales_pool_code, post.source_sales_pool_name, post.source_customer_code, post.source_customer_name, + source_customer.entity_name as source_customer_catalog_name, post.source_project_code, post.source_project_name, post.secondary_grouping_key as project_field, @@ -135,6 +141,12 @@ async def _load_posts( on author.user_account_id = post.author_account_id left join corporate_entity customer on customer.corporate_entity_id = post.corporate_entity_id + left join corporate_entity source_company + on source_company.corporate_entity_code = nullif(btrim(post.source_company_code), '') + left join process_unit source_process_unit + on source_process_unit.process_unit_code = nullif(btrim(post.source_process_unit_code), '') + left join corporate_entity source_customer + on source_customer.corporate_entity_code = nullif(btrim(post.source_customer_code), '') where nullif(btrim(post.source_draft_code), '') is null and nullif(btrim(post.source_deleted_flag), '') is null and not ( diff --git a/scripts/requeue_failed_post_content.py b/scripts/requeue_failed_post_content.py new file mode 100644 index 000000000..3e3e84da4 --- /dev/null +++ b/scripts/requeue_failed_post_content.py @@ -0,0 +1,78 @@ +"""Explicitly retry one terminal post-content ingestion job.""" + +from __future__ import annotations + +import argparse +import asyncio + +import asyncpg +import redis.asyncio as redis + +from backend.app.config import load_settings +from backend.app.post_content_queue import ( + publish_post_content_event, + requeue_failed_post_content_job, +) + + +def _parser() -> argparse.ArgumentParser: + """Build the operator-only command-line parser.""" + parser = argparse.ArgumentParser( + description="Explicitly requeue one failed post-content ingestion job." + ) + parser.add_argument("--post-id", required=True) + parser.add_argument("--target-dsn") + parser.add_argument("--valkey-url") + return parser + + +async def requeue_post_content( + post_id: str, + *, + target_dsn: str, + valkey_url: str, +) -> None: + """Reset one failed job, append its audit event, and publish its wake-up.""" + connection = await asyncpg.connect(target_dsn) + client = redis.from_url(valkey_url, decode_responses=True) + try: + body_row = await connection.fetchrow( + "select post_body from source_post where post_id = $1::uuid", + post_id, + ) + if body_row is None: + raise ValueError(f"source post does not exist: {post_id}") + async with connection.transaction(): + request = await requeue_failed_post_content_job( + connection, + post_id, + str(body_row["post_body"] or ""), + ) + entry_id = await publish_post_content_event( + client, + post_id=request.post_id, + source_body_digest=request.source_body_sha256, + ) + if entry_id is None: + raise RuntimeError("Valkey did not publish the explicit retry wake-up") + print({"post_id": post_id, "status": request.status_code, "published": True}) + finally: + await connection.close() + await client.aclose() + + +def main() -> None: + """Parse the target and run one explicit terminal-job recovery.""" + args = _parser().parse_args() + settings = load_settings() + asyncio.run( + requeue_post_content( + args.post_id, + target_dsn=args.target_dsn or settings.database_url, + valkey_url=args.valkey_url or settings.valkey_url, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/tests/test_chunking.py b/tests/test_chunking.py index 45cfb6a26..b95e05026 100644 --- a/tests/test_chunking.py +++ b/tests/test_chunking.py @@ -4,9 +4,9 @@ ConversationTurn, chunk_by_conversation_turn, chunk_by_dom, - chunk_by_source_body, chunk_by_paragraph, chunk_by_sentence, + chunk_by_source_body, normalize_semantic_text, ) @@ -127,6 +127,48 @@ def test_chunk_by_dom_labels_markerless_footnotes() -> None: ] +def test_chunk_by_dom_labels_html_and_word_footnote_markup() -> None: + html = ( + "

    Body text

    " + '
    1. HTML footnote body

    ' + '

    1 Word footnote body

    ' + ) + + chunks = chunk_by_dom(html) + + assert [(chunk.label, chunk.text) for chunk in chunks] == [ + ("p", "Body text"), + ("footnote", "HTML footnote body"), + ("footnote", "1 Word footnote body"), + ] + + +def test_chunk_by_dom_does_not_label_body_footnote_citation_as_footnote() -> None: + html = ( + '

    Body cites [1].

    ' + '

    [1] Footnote definition.

    ' + ) + + chunks = chunk_by_dom(html) + + assert [(chunk.label, chunk.text) for chunk in chunks] == [ + ("p", "Body cites [1]."), + ("footnote", "[1] Footnote definition."), + ] + + +def test_chunk_by_dom_labels_ooxml_footnote_containers() -> None: + chunks = chunk_by_dom( + "OOXML footnote body" + "OOXML endnote body" + ) + + assert [(chunk.label, chunk.text) for chunk in chunks] == [ + ("footnote", "OOXML footnote body"), + ("footnote", "OOXML endnote body"), + ] + + def test_chunk_by_dom_word_table_rows_also_group_cells() -> None: html = "1Acme Corp" chunks = chunk_by_dom(html) @@ -141,6 +183,7 @@ def test_chunk_by_dom_keeps_indentation_as_metadata_not_embedding_text() -> None assert [chunk.text for chunk in chunks] == ["Level one", "Level two"] assert [chunk.indent_width for chunk in chunks] == [2, 4] + assert [chunk.declared_indent_width for chunk in chunks] == [0, 0] def test_chunk_by_dom_reads_html_and_word_indentation_declarations() -> None: @@ -153,6 +196,7 @@ def test_chunk_by_dom_reads_html_and_word_indentation_declarations() -> None: assert [chunk.text for chunk in chunks] == ["HTML", "Word"] assert [chunk.indent_width for chunk in chunks] == [4, 4] + assert [chunk.declared_indent_width for chunk in chunks] == [4, 4] def test_chunk_by_dom_reads_the_css_margin_shorthand_not_just_margin_left() -> None: diff --git a/tests/test_image_content.py b/tests/test_image_content.py index f694635a4..de7fc49b5 100644 --- a/tests/test_image_content.py +++ b/tests/test_image_content.py @@ -238,7 +238,7 @@ def test_region_coverage_guard_rejects_a_salient_crop() -> None: from lineageweave.image_content import ImageRegion assert not regions_cover_image((ImageRegion(0.2, 0.2, 0.3, 0.3),)) - assert regions_cover_image((ImageRegion(0.0, 0.0, 1.0, 1.0),)) + assert not regions_cover_image((ImageRegion(0.0, 0.0, 1.0, 1.0),)) def test_parse_description_does_not_absorb_unknown_labels_into_tags() -> None: diff --git a/tests/test_person_mention_projection.py b/tests/test_person_mention_projection.py index 602c0bd86..7252fb8e8 100644 --- a/tests/test_person_mention_projection.py +++ b/tests/test_person_mention_projection.py @@ -44,7 +44,10 @@ from lineageweave.post_summary import ( ACTOR_TYPE_ORGANIZATION, ACTOR_TYPE_PERSON, + KeyEvent, + MajorEventAction, PostSummary, + ProjectMention, RoleResponsibility, ) @@ -64,6 +67,16 @@ _MAJOR_EVENT_ACTION_MIGRATION = ( Path(__file__).resolve().parents[1] / "migrations" / "0100_major_event_action.sql" ) +_PROJECT_BOUND_ACTION_MIGRATION = ( + Path(__file__).resolve().parents[1] + / "migrations" + / "0101_project_bound_major_event_action.sql" +) +_PROJECT_BOUND_EVENT_MIGRATION = ( + Path(__file__).resolve().parents[1] + / "migrations" + / "0102_project_bound_summary_event.sql" +) _SEMANTIC_SEARCH_MIGRATION = ( Path(__file__).resolve().parents[1] / "migrations" / "0032_semantic_search_trigram.sql" ) @@ -150,6 +163,8 @@ def projection_database() -> str: cursor.execute(_POST_SUMMARY_CONTRACT_MIGRATION.read_text(encoding="utf-8")) cursor.execute(_SUMMARY_FIVE_W1H_MIGRATION.read_text(encoding="utf-8")) cursor.execute(_MAJOR_EVENT_ACTION_MIGRATION.read_text(encoding="utf-8")) + cursor.execute(_PROJECT_BOUND_ACTION_MIGRATION.read_text(encoding="utf-8")) + cursor.execute(_PROJECT_BOUND_EVENT_MIGRATION.read_text(encoding="utf-8")) cursor.execute( """ insert into common_lookup_value @@ -252,14 +267,50 @@ async def _exercise_projection_contract( post_id, PostSummary( korean_summary="합성 요약", + key_event_details=( + KeyEvent(event_text="합성 프로젝트 검토", project_key="Synthetic Project"), + ), roles_and_responsibilities=( RoleResponsibility( actor_name="Summary Person", responsibility="검토", ), ), + major_event_actions=( + MajorEventAction( + action_text="합성 프로젝트 검토 요청", + requester_actor_name="Summary Person", + processor_actor_name=None, + evidence_text="합성 본문에 프로젝트 검토 요청이 기록됨", + project_key="Synthetic Project", + ), + MajorEventAction( + action_text="연결되지 않은 프로젝트 요청", + requester_actor_name=None, + processor_actor_name=None, + evidence_text="프로젝트 연결 근거가 없음", + project_key="unsupported-project", + ), + ), + project_mentions=( + ProjectMention( + project_name="Synthetic Project", + canonical_name="Synthetic Project", + evidence="합성 본문에 프로젝트명이 있음", + confidence=0.9, + ), + ), ), ) + summary_payload = await fetch_persisted_summary(connection, post_id) + assert summary_payload is not None + assert [ + action["project_name"] + for action in summary_payload["major_event_actions"] + ] == ["Synthetic Project", None] + assert summary_payload["key_event_details"] == [ + {"event_text": "합성 프로젝트 검토", "project_name": "Synthetic Project"} + ] keyman_rows = await connection.fetch( "select person_id from post_person_mention where post_id = $1", diff --git a/tests/test_post_content_normalization.py b/tests/test_post_content_normalization.py index 2342e6ad4..0beead6f4 100644 --- a/tests/test_post_content_normalization.py +++ b/tests/test_post_content_normalization.py @@ -11,7 +11,12 @@ import base64 from threading import Lock -from lineageweave.image_content import ImageDescription, ImageRegion +from lineageweave.chunking import Chunk +from lineageweave.image_content import ( + ImageDescription, + ImageRegion, + NullImageContentClient, +) from lineageweave.llm_context import current_llm_metadata, use_llm_metadata from lineageweave.post_content_normalization import normalize_post_body @@ -49,16 +54,61 @@ def describe(self, image_bytes: bytes, mime_type: str) -> ImageDescription: return super().describe(image_bytes, mime_type) -class _RegionVisionClient(_FakeVisionClient): +class _FullImageRegionVisionClient(_FakeVisionClient): def locate_regions(self, image_bytes: bytes, mime_type: str) -> tuple[ImageRegion, ...]: return (ImageRegion(0.0, 0.0, 1.0, 1.0),) class _PartialRegionVisionClient(_FakeVisionClient): + def __init__(self, description: ImageDescription, fail_on_call: int | None = None) -> None: + super().__init__(description) + self.describe_calls = 0 + self.fail_on_call = fail_on_call + + def describe(self, image_bytes: bytes, mime_type: str) -> ImageDescription: + self.describe_calls += 1 + if self.describe_calls == self.fail_on_call: + raise RuntimeError("synthetic parent-image provider outage") + return super().describe(image_bytes, mime_type) + def locate_regions(self, image_bytes: bytes, mime_type: str) -> tuple[ImageRegion, ...]: return (ImageRegion(0.25, 0.25, 0.25, 0.25),) +class _MixedValidityRegionVisionClient(_PartialRegionVisionClient): + def locate_regions(self, image_bytes: bytes, mime_type: str) -> tuple[ImageRegion, ...]: + return ( + ImageRegion(0.25, 0.25, 0.25, 0.25), + ImageRegion(-0.1, 0.0, 0.5, 0.5), + ImageRegion(0.0, 0.0, float("nan"), 0.5), + ImageRegion(None, 0.0, 0.5, 0.5), # type: ignore[arg-type] + object(), # type: ignore[arg-type] + ) + + +class _LocatorFailureVisionClient(_FakeVisionClient): + def locate_regions(self, image_bytes: bytes, mime_type: str) -> tuple[ImageRegion, ...]: + raise RuntimeError("synthetic locator outage") + + +class _EmptyLocatorVisionClient(_FakeVisionClient): + def locate_regions(self, image_bytes: bytes, mime_type: str) -> tuple[ImageRegion, ...]: + return None # type: ignore[return-value] + + +class _MalformedLocatorVisionClient(_FakeVisionClient): + def locate_regions(self, image_bytes: bytes, mime_type: str) -> tuple[ImageRegion, ...]: + return object() # type: ignore[return-value] + + +class _PartialRegionFailureVisionClient(_FakeVisionClient): + def locate_regions(self, image_bytes: bytes, mime_type: str) -> tuple[ImageRegion, ...]: + return (ImageRegion(0.25, 0.25, 0.25, 0.25),) + + def describe(self, image_bytes: bytes, mime_type: str) -> ImageDescription: + raise RuntimeError("synthetic region and parent outage") + + def test_plain_text_passes_through_unchanged() -> None: result = normalize_post_body("Just a plain business record, no markup here.") assert result.text == "Just a plain business record, no markup here." @@ -116,22 +166,130 @@ def test_image_is_described_and_placed_at_its_document_position_not_dropped() -> assert result.image_descriptions == (description,) -def test_image_regions_are_cropped_and_described_as_independent_evidence() -> None: +def test_single_full_image_locator_response_keeps_parent_evidence_without_region() -> None: b64 = base64.b64encode(_PNG_1X1).decode("ascii") html = f'

    Before.

    After.

    ' description = ImageDescription( extracted_text="panel text", caption="one visual panel", tags=("panel",) ) - result = normalize_post_body(html, vision_client=_RegionVisionClient(description)) + result = normalize_post_body(html, vision_client=_FullImageRegionVisionClient(description)) assert result.image_results[0].status_code == "described" - assert result.image_results[0].regions[0].region == ImageRegion(0.0, 0.0, 1.0, 1.0) - assert result.image_results[0].regions[0].description == description + assert result.image_results[0].regions == () + assert result.image_results[0].description == description assert "panel text" in result.text -def test_parallel_image_analysis_preserves_post_scoped_llm_metadata() -> None: +def test_image_without_ocr_uses_caption_only_and_preserves_image_result() -> None: + b64 = base64.b64encode(_PNG_1X1).decode("ascii") + description = ImageDescription(extracted_text="", caption="a blank chart", tags=()) + + result = normalize_post_body( + f'', + vision_client=_FakeVisionClient(description), + ) + + assert result.text == "[image: a blank chart]" + assert result.image_results[0].status_code == "described" + + +def test_unavailable_vision_channel_keeps_an_explicit_image_outcome() -> None: + b64 = base64.b64encode(_PNG_1X1).decode("ascii") + + result = normalize_post_body( + f'', + vision_client=NullImageContentClient(), + ) + + assert result.text == "[image: content unavailable]" + assert result.image_results[0].status_code == "unavailable" + + +def test_available_client_with_missing_image_bytes_keeps_unavailable_outcome() -> None: + from lineageweave.post_content_normalization import _describe_image_chunk + + result, description, placeholder = _describe_image_chunk( + Chunk(text="", unit_type="image", index=0, label="image/png", image_data=None), + _FakeVisionClient(ImageDescription(extracted_text="", caption="unused", tags=())), + ) + + assert result.status_code == "unavailable" + assert description is None + assert placeholder == "[image: content unavailable]" + + +def test_locator_failure_falls_back_to_parent_image_evidence() -> None: + b64 = base64.b64encode(_PNG_1X1).decode("ascii") + description = ImageDescription(extracted_text="parent", caption="whole image", tags=()) + + result = normalize_post_body( + f'', + vision_client=_LocatorFailureVisionClient(description), + ) + + assert result.image_results[0].status_code == "described" + assert result.image_results[0].regions == () + assert result.image_results[0].description == description + + +def test_empty_locator_result_falls_back_to_parent_image_evidence() -> None: + b64 = base64.b64encode(_PNG_1X1).decode("ascii") + description = ImageDescription(extracted_text="parent", caption="whole image", tags=()) + + result = normalize_post_body( + f'', + vision_client=_EmptyLocatorVisionClient(description), + ) + + assert result.image_results[0].status_code == "described" + assert result.image_results[0].regions == () + assert result.image_results[0].description == description + + +def test_non_iterable_locator_result_falls_back_to_parent_image_evidence() -> None: + b64 = base64.b64encode(_PNG_1X1).decode("ascii") + description = ImageDescription(extracted_text="parent", caption="whole image", tags=()) + + result = normalize_post_body( + f'', + vision_client=_MalformedLocatorVisionClient(description), + ) + + assert result.image_results[0].status_code == "described" + assert result.image_results[0].regions == () + assert result.image_results[0].description == description + + +def test_partial_locator_with_no_successful_description_fails_closed() -> None: + b64 = base64.b64encode(_PNG_1X1).decode("ascii") + + result = normalize_post_body( + f'', + vision_client=_PartialRegionFailureVisionClient( + ImageDescription(extracted_text="unused", caption="unused", tags=()) + ), + ) + + assert result.image_results[0].status_code == "failed" + assert result.text == "[image: content unavailable]" + + +def test_unknown_chunk_kinds_are_not_leaked_into_buyer_text(monkeypatch) -> None: + from lineageweave import post_content_normalization + + monkeypatch.setattr( + post_content_normalization, + "chunk_by_dom", + lambda _body: [Chunk(text="hidden", unit_type="unknown", index=0)], + ) + + result = normalize_post_body("
    ignored by the synthetic chunker
    ") + + assert result.text == "" + + +def test_image_analysis_preserves_post_scoped_llm_metadata() -> None: b64 = base64.b64encode(_PNG_1X1).decode("ascii") html = ( f'' @@ -152,17 +310,62 @@ def test_parallel_image_analysis_preserves_post_scoped_llm_metadata() -> None: assert all(seen == metadata for seen in client.seen_metadata) -def test_partial_region_response_falls_back_to_full_image_evidence() -> None: +def test_partial_region_response_retains_panel_and_parent_evidence() -> None: b64 = base64.b64encode(_PNG_1X1).decode("ascii") html = f'' + client = _PartialRegionVisionClient( + ImageDescription(extracted_text="whole image", caption="whole", tags=()) + ) + result = normalize_post_body(html, vision_client=client) + + assert result.image_results[0].regions[0].region == ImageRegion(0.25, 0.25, 0.25, 0.25) + assert client.describe_calls == 2 + + +def test_partial_region_parent_failure_keeps_successful_panel_evidence() -> None: + b64 = base64.b64encode(_PNG_1X1).decode("ascii") + client = _PartialRegionVisionClient( + ImageDescription(extracted_text="panel", caption="panel", tags=()), + fail_on_call=2, + ) + result = normalize_post_body( - html, - vision_client=_PartialRegionVisionClient( - ImageDescription(extracted_text="whole image", caption="whole", tags=()) - ), + f'', + vision_client=client, + ) + + assert result.image_results[0].status_code == "described" + assert result.image_results[0].regions[0].description is not None + assert result.image_results[0].description is not None + + +def test_partial_region_analysis_discards_unbounded_locator_regions() -> None: + b64 = base64.b64encode(_PNG_1X1).decode("ascii") + client = _MixedValidityRegionVisionClient( + ImageDescription(extracted_text="whole", caption="whole", tags=()) ) - assert result.image_results[0].regions[0].region == ImageRegion(0.0, 0.0, 1.0, 1.0) + result = normalize_post_body( + f'', + vision_client=client, + ) + + assert len(result.image_results[0].regions) == 1 + assert result.image_results[0].regions[0].region == ImageRegion(0.25, 0.25, 0.25, 0.25) + + +def test_non_iterable_locator_result_falls_back_to_parent_evidence() -> None: + b64 = base64.b64encode(_PNG_1X1).decode("ascii") + description = ImageDescription(extracted_text="parent", caption="whole", tags=()) + + result = normalize_post_body( + f'', + vision_client=_MalformedLocatorVisionClient(description), + ) + + assert result.image_results[0].status_code == "described" + assert result.image_results[0].regions == () + assert result.image_results[0].description == description def test_comparison_operators_in_plain_text_are_not_treated_as_html() -> None: diff --git a/tests/test_post_content_persistence_edges.py b/tests/test_post_content_persistence_edges.py index 89bc77708..24bec9e6d 100644 --- a/tests/test_post_content_persistence_edges.py +++ b/tests/test_post_content_persistence_edges.py @@ -4,6 +4,8 @@ from contextlib import asynccontextmanager from types import SimpleNamespace +import pytest + from lineageweave.chunking import chunk_by_dom from lineageweave.image_content import ImageRegion from lineageweave.post_content_normalization import ( @@ -12,7 +14,12 @@ ImageRegionResult, NormalizedPostContent, ) -from lineageweave.post_content_persistence import _render_image_text, persist_post_content +from lineageweave.post_content_persistence import ( + _bounded_unit_batches, + _render_image_text, + persist_post_content, +) +from lineageweave.post_structure import StructureDecision def _persist(*args: object, **kwargs: object) -> int: @@ -72,6 +79,59 @@ def embed(self, _text: str) -> list[float]: raise AssertionError("unavailable channel must not be called") +class _FailingStructure: + """Represent an expected structure-channel response failure.""" + + available = True + + def infer( + self, _post_title: str, _units: list[dict[str, object]] + ) -> tuple[StructureDecision, ...]: + """Raise the response-validation error handled by persistence.""" + raise ValueError("synthetic invalid structure response") + + +class _UnexpectedChannelFailure: + """Represent a programming defect that persistence must expose.""" + + available = True + + def embed_many(self, _texts: list[str]) -> list[list[float]]: + """Raise a defect outside the expected channel-failure contract.""" + raise AssertionError("synthetic programming defect") + + def infer( + self, _post_title: str, _units: list[dict[str, object]] + ) -> tuple[StructureDecision, ...]: + """Raise the same defect from the structure-channel boundary.""" + raise AssertionError("synthetic programming defect") + + +class _ResolvedStructure: + """Return one applicable and one out-of-scope structure decision.""" + + available = True + + def infer( + self, _post_title: str, units: list[dict[str, object]] + ) -> tuple[StructureDecision, ...]: + """Return bounded synthetic decisions for persistence filtering.""" + return ( + StructureDecision( + unit_index=int(units[0]["unit_index"]), + indent_level=2, + confidence=0.9, + evidence="Synthetic semantic nesting evidence.", + ), + StructureDecision( + unit_index=999, + indent_level=9, + confidence=0.1, + evidence="Out-of-scope synthetic decision.", + ), + ) + + def test_render_image_text_preserves_unavailable_and_caption_variants() -> None: assert _render_image_text(None) == "[image: content unavailable]" assert _render_image_text(ImageContentResult(0, "image/png", "failed")) == "[image: content unavailable]" @@ -120,6 +180,12 @@ def test_persists_image_tags_formatting_and_embeddings() -> None: "described", SimpleNamespace(caption="panel", extracted_text="panel OCR", tags=("panel",)), ), + ImageRegionResult( + 1, + ImageRegion(0.1, 0.1, 0.5, 0.5), + "unavailable", + None, + ), ), ), ), @@ -176,3 +242,95 @@ def test_unavailable_embedding_channel_is_skipped_and_empty_body_is_safe() -> No == 0 ) assert not any("post_content_embedding" in query for query, _args in conn.fetchvals) + + +def test_source_only_whitespace_is_not_persisted_as_explicit_depth() -> None: + """Presentation alignment must not become authoritative hierarchy.""" + conn = _Connection() + + assert ( + _persist( + conn, + "post-4", + "

      First item

        Second item

    ", + ) + == 2 + ) + + structure_rows = [ + args + for query, args in conn.executed + if "insert into post_content_unit_structure" in query + ] + assert [(args[1], args[2]) for args in structure_rows] == [ + (0, "unresolved"), + (0, "unresolved"), + ] + + +def test_expected_structure_failure_remains_unresolved_for_retry() -> None: + """Keep an invalid provider response absent without losing source units.""" + conn = _Connection() + + assert ( + _persist(conn, "post-5", "plain text", structure_client=_FailingStructure()) + == 1 + ) + assert any( + args[2] == "unresolved" + for query, args in conn.executed + if "insert into post_content_unit_structure" in query + ) + + +@pytest.mark.parametrize( + "channel_kwargs", + ( + { + "embedding_client": _UnexpectedChannelFailure(), + "embedding_model_code": "embedding-model", + }, + {"structure_client": _UnexpectedChannelFailure()}, + ), +) +def test_unexpected_channel_defects_propagate( + channel_kwargs: dict[str, object], +) -> None: + """Expose programming defects so the durable worker records the failure.""" + with pytest.raises(AssertionError, match="synthetic programming defect"): + _persist(_Connection(), "post-6", "plain text", **channel_kwargs) + + +def test_bounded_batches_cover_empty_count_and_character_limits() -> None: + """Preserve generic keys while enforcing both provider request bounds.""" + assert _bounded_unit_batches([]) == [] + count_bounded = _bounded_unit_batches([(str(i), "x") for i in range(33)]) + assert [len(batch) for batch in count_bounded] == [32, 1] + assert [ + len(batch) + for batch in _bounded_unit_batches([(str(i), "x" * 12_001) for i in range(3)]) + ] == [1, 1, 1] + + +def test_explicit_and_adjudicated_structure_are_persisted_by_unit() -> None: + """Persist explicit depth and only in-scope orchestrator decisions.""" + conn = _Connection() + + assert ( + _persist( + conn, + "post-7", + '

    Explicit

    Semantic

    ', + structure_client=_ResolvedStructure(), + ) + == 2 + ) + structure_rows = [ + args + for query, args in conn.executed + if "insert into post_content_unit_structure" in query + ] + assert [(args[1], args[2]) for args in structure_rows] == [ + (1, "explicit"), + (2, "llm"), + ] diff --git a/tests/test_post_content_queue.py b/tests/test_post_content_queue.py index 7071f7d80..3f8c1398b 100644 --- a/tests/test_post_content_queue.py +++ b/tests/test_post_content_queue.py @@ -4,8 +4,11 @@ import asyncio import re +from datetime import timedelta from pathlib import Path +import pytest + from backend.app.post_content_queue import ( FAILED, POST_CONTENT_RETRY_INTERVAL, @@ -14,6 +17,8 @@ QUEUED, RUNNING, SUCCEEDED, + record_post_content_backfill_success, + requeue_failed_post_content_job, post_content_api_status, post_content_is_complete, post_content_stream_fields, @@ -262,11 +267,104 @@ async def execute(self, query: str, *args: object) -> None: def test_recovery_query_carries_one_bounded_retry_interval() -> None: - assert POST_CONTENT_RETRY_INTERVAL == "5 minutes" + assert POST_CONTENT_RETRY_INTERVAL == timedelta(minutes=5) migration = (_ROOT / "migrations" / "0050_post_content_ingestion_queue.sql").read_text() assert "queued_at timestamptz not null" in migration +def test_explicit_retry_resets_only_one_failed_job() -> None: + executed: list[tuple[str, tuple[object, ...]]] = [] + + class FakeConnection: + async def fetchrow(self, query: str, *_args: object): + assert "for update" in query + return {"status_code": FAILED} + + async def fetchval(self, query: str, *_args: object) -> int: + assert "status_ordinal" in query + return 4 + + async def execute(self, query: str, *args: object) -> str: + executed.append((query, args)) + return "UPDATE 1" if query.lstrip().startswith("update") else "INSERT 0 1" + + request = asyncio.run( + requeue_failed_post_content_job( + FakeConnection(), + "00000000-0000-0000-0000-000000000001", + "current body", + ) + ) + + assert request.status_code == QUEUED + assert request.should_publish is True + assert request.source_body_sha256 == source_body_sha256("current body") + assert len(executed) == 2 + assert "attempt_count = 0" in executed[0][0] + assert executed[1][1][-1] == "operator requested an explicit post-content retry" + + +def test_explicit_retry_rejects_missing_and_nonterminal_jobs() -> None: + """The operator command cannot create a job or reset an active job.""" + + class MissingConnection: + async def fetchrow(self, _query: str, *_args: object): + return None + + with pytest.raises(ValueError, match="does not exist"): + asyncio.run( + requeue_failed_post_content_job( + MissingConnection(), + "00000000-0000-0000-0000-000000000001", + "current body", + ) + ) + + class QueuedConnection: + async def fetchrow(self, _query: str, *_args: object): + return {"status_code": QUEUED} + + with pytest.raises(ValueError, match="only a failed"): + asyncio.run( + requeue_failed_post_content_job( + QueuedConnection(), + "00000000-0000-0000-0000-000000000001", + "current body", + ) + ) + + +def test_backfill_success_clears_terminal_error_and_records_succeeded() -> None: + executed: list[tuple[str, tuple[object, ...]]] = [] + + class FakeConnection: + async def fetchrow(self, query: str, *_args: object): + assert "for update" in query + return {"status_code": FAILED} + + async def fetchval(self, query: str, *_args: object) -> int: + assert "status_ordinal" in query + return 5 + + async def execute(self, query: str, *args: object) -> str: + executed.append((query, args)) + return "UPDATE 1" if query.lstrip().startswith("update") else "INSERT 0 1" + + request = asyncio.run( + record_post_content_backfill_success( + FakeConnection(), + "00000000-0000-0000-0000-000000000001", + "current body", + ) + ) + + assert request.status_code == SUCCEEDED + assert request.should_publish is False + assert len(executed) == 2 + assert "last_error_code = null" in executed[0][0] + assert executed[1][1][-1] == "operator backfill persisted post-content evidence" + + def test_recovery_republishes_due_rows_in_queued_at_order() -> None: from contextlib import asynccontextmanager diff --git a/tests/test_post_content_worker.py b/tests/test_post_content_worker.py index dddace990..d92ed728f 100644 --- a/tests/test_post_content_worker.py +++ b/tests/test_post_content_worker.py @@ -230,6 +230,7 @@ async def persist(*_args, **_kwargs): updates = [args for query, args in connection.executed if "set status_code" in query] assert any(args[1] == QUEUED and args[6] == "post_content_ingestion_failed" for args in updates) + assert all("provider timeout" not in str(args) for args in updates) def test_failure_at_attempt_limit_is_terminal_and_visible() -> None: diff --git a/tests/test_post_summary.py b/tests/test_post_summary.py index 1cd6d420b..fe5f569df 100644 --- a/tests/test_post_summary.py +++ b/tests/test_post_summary.py @@ -26,6 +26,8 @@ NullPostSummaryClient, RoleResponsibility, _SUMMARY_REQUEST_PROMPT_TEMPLATE, + _parse_optional_project_key, + _parse_plain_summary_response, _parse_plain_summary_details, parse_summary_response, ) @@ -119,6 +121,71 @@ def test_parses_major_event_requester_and_processor() -> None: assert action.processor_actor_name == "김철수" +def test_parses_project_bound_major_event_action() -> None: + details = _parse_plain_summary_details( + "ROLES:\n" + "홍길동 | 변경 요청 | person | 당사\n" + "김철수 | 도면 수정 | person | 고객사\n" + "PROJECTS:\n" + "HVDC Pilot | hvdc-pilot | 파일럿 도면 | 0.9\n" + "ACTIONS:\n" + "도면 변경 승인 | hvdc-pilot | 홍길동 | 김철수 | 프로젝트 도면 근거" + ) + assert details is not None + assert details[2][0].project_key == "hvdc-pilot" + + +def test_legacy_action_preserves_pipe_in_evidence_text() -> None: + details = _parse_plain_summary_details( + "ROLES:\n" + "Synthetic requester | 요청 | person | Synthetic organization\n" + "Synthetic processor | 처리 | person | Synthetic organization\n" + "PROJECTS:\nNONE\n" + "ACTIONS:\n" + "합성 조치 | Synthetic requester | Synthetic processor | 첫 근거 | 추가 근거" + ) + assert details is not None + assert details[2][0].project_key is None + assert details[2][0].evidence_text == "첫 근거 | 추가 근거" + + +def test_json_project_name_is_normalized_for_legacy_action_contract() -> None: + summary = parse_summary_response( + '{"korean_summary":"요약", "major_event_actions":[' + '{"action_text":"검토", "project_name":"HVDC Pilot", ' + '"evidence_text":"본문 근거"}]}' + ) + assert summary is not None + assert summary.major_event_actions[0].project_key == "hvdc-pilot" + + +def test_parses_project_bound_key_event_without_leaking_internal_key_to_text() -> None: + summary = parse_summary_response( + '{"korean_summary":"요약", "key_events":[{"event_text":"도면 검토",' + '"project_key":"HVDC Pilot"}]}' + ) + assert summary is not None + assert summary.key_events == ("도면 검토",) + assert summary.key_event_details[0].project_key == "hvdc-pilot" + + +def test_parses_project_bound_plain_key_event() -> None: + parsed = _parse_plain_summary_response( + "회의 요약\nKEY EVENTS: hvdc-pilot :: 도면 검토; NONE :: 공통 일정 확인" + ) + assert parsed is not None + _summary, events, details = parsed + assert events == ("도면 검토", "공통 일정 확인") + assert details[0].project_key == "hvdc-pilot" + assert details[1].project_key is None + + +def test_optional_project_key_normalizes_unicode_and_rejects_sentinels() -> None: + assert _parse_optional_project_key(" Project Ω ") == "project-ω" + for sentinel in (None, "", " ", "None", "N/A", "unknown", 42): + assert _parse_optional_project_key(sentinel) is None + + def test_organization_actor_is_not_forced_into_a_person_slot() -> None: """A named actor that is genuinely an organization (e.g. our own company acting in its own name, not a named individual) must parse diff --git a/tests/test_rankweave_client.py b/tests/test_rankweave_client.py index 64c5e70b1..29bda2e04 100644 --- a/tests/test_rankweave_client.py +++ b/tests/test_rankweave_client.py @@ -76,11 +76,12 @@ def boom() -> object: monkeypatch.setattr("lineageweave.rankweave_client._import_rankweave", boom) client = RankWeaveClient(transport=LibraryRankWeaveTransport()) - with pytest.raises(RankWeaveNotAvailable, match="rankweave_not_available"): + with pytest.raises(RankWeaveNotAvailable, match="rankweave_not_available") as error: client.fuse_rankings( {"temporal": ["post-1"], "lexical": ["post-1"]}, {"post-1": "Public post"}, ) + assert "duplicate identifiers" not in str(error.value) assert ( client.as_api_payload([PUBLIC], can_see_post=lambda _row: True)["rankings"] == [] diff --git a/tests/test_schema.py b/tests/test_schema.py index b0a72f39c..1e2c708a3 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -30,6 +30,19 @@ _MAJOR_EVENT_ACTION_MIGRATION = ( Path(__file__).resolve().parents[1] / "migrations" / "0100_major_event_action.sql" ) +_PROJECT_MENTION_MIGRATION = ( + Path(__file__).resolve().parents[1] / "migrations" / "0031_semantic_project_mentions.sql" +) +_PROJECT_BOUND_ACTION_MIGRATION = ( + Path(__file__).resolve().parents[1] + / "migrations" + / "0101_project_bound_major_event_action.sql" +) +_PROJECT_BOUND_EVENT_MIGRATION = ( + Path(__file__).resolve().parents[1] + / "migrations" + / "0102_project_bound_summary_event.sql" +) def _postgres_available() -> bool: @@ -62,7 +75,10 @@ def schema_db(): try: with conn.cursor() as cur: cur.execute(_MIGRATION_PATH.read_text()) + cur.execute(_PROJECT_MENTION_MIGRATION.read_text()) cur.execute(_MAJOR_EVENT_ACTION_MIGRATION.read_text()) + cur.execute(_PROJECT_BOUND_ACTION_MIGRATION.read_text()) + cur.execute(_PROJECT_BOUND_EVENT_MIGRATION.read_text()) conn.commit() yield conn finally: @@ -91,6 +107,7 @@ def test_migration_applies_cleanly(schema_db) -> None: "abac_policy", "source_post", "post_counterparty_entity", + "post_project_mention", "cataloged_person", "person_affiliation", "post_person_mention", @@ -115,6 +132,30 @@ def test_migration_applies_cleanly(schema_db) -> None: assert expected <= tables +def test_major_event_action_project_reference_is_normalized(schema_db) -> None: + with schema_db.cursor() as cur: + cur.execute( + """ + select confrelid::regclass::text + from pg_constraint + where conname = 'post_summary_action_project_mention_fk' + """ + ) + assert cur.fetchone()[0] == "post_project_mention" + + +def test_summary_event_project_reference_is_normalized(schema_db) -> None: + with schema_db.cursor() as cur: + cur.execute( + """ + select confrelid::regclass::text + from pg_constraint + where conname = 'post_summary_event_project_mention_fk' + """ + ) + assert cur.fetchone()[0] == "post_project_mention" + + def test_leftover_pair_references_member_and_item_rows(schema_db) -> None: """A leftover pair cannot name a post or item from another report.""" with schema_db.cursor() as cur: diff --git a/tests/test_semantic_hints.py b/tests/test_semantic_hints.py index 6eff90578..97e180377 100644 --- a/tests/test_semantic_hints.py +++ b/tests/test_semantic_hints.py @@ -21,6 +21,9 @@ def test_semantic_hints_keep_explicit_project_pool_and_author_sources() -> None: source_company_code="SOURCE-COMPANY", source_business_unit_code="SOURCE-BU", source_customer_code="SOURCE-CUSTOMER", + source_company_catalog_name="Catalog Company", + source_process_unit_catalog_name="Catalog PU", + source_customer_catalog_name="Catalog Customer", source_project_code="SOURCE-PROJECT", source_company_name="Named company", source_process_unit_name="Named PU", @@ -43,6 +46,23 @@ def test_semantic_hints_keep_explicit_project_pool_and_author_sources() -> None: assert "source_project_code=SOURCE-PROJECT" in hints assert "source_company_name=Named company [source_field=source_post.source_company_name]" in hints assert "source_process_unit_name=Named PU [source_field=source_post.source_process_unit_name]" in hints + assert "source_company_catalog_name=Catalog Company [source_lookup=corporate_entity.corporate_entity_code]" in hints + assert "source_process_unit_catalog_name=Catalog PU [source_lookup=process_unit.process_unit_code]" in hints + assert "source_customer_catalog_name=Catalog Customer [source_lookup=corporate_entity.corporate_entity_code]" in hints + + +def test_catalog_lookup_hint_reports_a_code_without_inventing_a_name() -> None: + hints = format_semantic_hints( + author_name=None, + author_affiliations=(), + order_pool_code=None, + order_pool_name=None, + project_field=None, + customer_name=None, + source_company_code="UNRESOLVED-COMPANY", + ) + + assert "source_company_catalog_name=none [source_lookup=corporate_entity.corporate_entity_code]" in hints def test_unknown_customer_is_a_weak_hint_not_project_evidence() -> None: diff --git a/tests/test_stale_summary_continuity.py b/tests/test_stale_summary_continuity.py new file mode 100644 index 000000000..ef4f66184 --- /dev/null +++ b/tests/test_stale_summary_continuity.py @@ -0,0 +1,36 @@ +"""Regression tests for buyer-visible stale summary continuity.""" + +import asyncio + +from backend.app.post_summary_ingestion import fetch_persisted_summary +from lineageweave.post_summary import POST_SUMMARY_CONTRACT_VERSION + + +class _StaleSummaryConnection: + """Minimal asyncpg-shaped fake containing one legacy summary header.""" + + async def fetchrow(self, query: str, post_id: str) -> dict[str, object]: + return { + "korean_summary": "Previously persisted evidence.", + "summary_contract_version": POST_SUMMARY_CONTRACT_VERSION - 1, + } + + async def fetch(self, query: str, post_id: str) -> list[dict[str, object]]: + return [] + + +def test_stale_summary_is_hidden_by_default() -> None: + """Current-contract reads must not silently present legacy semantics.""" + result = asyncio.run(fetch_persisted_summary(_StaleSummaryConnection(), "post-id")) + assert result is None + + +def test_stale_summary_can_be_returned_with_explicit_status() -> None: + """The continuity path exposes the old contract so the UI can label it.""" + result = asyncio.run( + fetch_persisted_summary(_StaleSummaryConnection(), "post-id", allow_stale=True) + ) + assert result is not None + assert result["summary_status"] == "stale" + assert result["summary_contract_version"] == POST_SUMMARY_CONTRACT_VERSION - 1 + assert result["korean_summary"] == "Previously persisted evidence." diff --git a/update_app.py b/update_app.py new file mode 100644 index 000000000..fe9e7eb61 --- /dev/null +++ b/update_app.py @@ -0,0 +1,28 @@ +import re + +with open("frontend/src/App.tsx", "r") as f: + content = f.read() + +# Replace hardcoded LineageWeave and BRAND in App component +# I'll inject `const brandName = "LineageWeave"; // TODO: Fetch from admin/tenant config` +# into the App component. + +# First, find the beginning of the App component: +# export default function App() { +# const auth = useAuth(); +app_start = "export default function App() {\n const auth = useAuth();" +new_app_start = "export default function App() {\n const auth = useAuth();\n const brandName = \"LineageWeave\"; // TODO: Fetch from admin/tenant config" + +content = content.replace(app_start, new_app_start) + +# Replace

    LineageWeave

    with

    {brandName}

    +content = content.replace("

    LineageWeave

    ", "

    {brandName}

    ") +# Replace

    LineageWeave

    with

    {brandName}

    +content = content.replace('

    LineageWeave

    ', '

    {brandName}

    ') +# Replace LineageWeave with {brandName} +content = content.replace('LineageWeave', '{brandName}') +# Replace by BRAND with by {brandName} +content = content.replace('by BRAND.', 'by {brandName}.') + +with open("frontend/src/App.tsx", "w") as f: + f.write(content)