-
Notifications
You must be signed in to change notification settings - Fork 1
feat: add evidence-honest Global Ask knowledge cutoff (v2.23.0) #301
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
ce4663c
03a1167
d25eeda
0aaf992
3e5bfca
8966294
943ab63
8e5be3e
3f4c41e
4b80c87
c96fa28
c23b81a
b78b8c5
be0a544
aad290b
8cf3491
4e4dae5
1b73e66
796af57
a409895
821432a
c616a4a
f74377f
3e37f02
f010d9b
6cf7f3b
f68b8d7
af9d25a
06a5b37
8431998
4e601f6
757757b
2234fc7
0664679
4c3e99a
7bb9a36
0814949
ad1d5fa
c75f37b
c910478
85db4e1
8daa419
156a507
5267614
dc00316
15c82c7
8fb0fea
7f36683
a731551
4d6494d
860d07b
176dc74
219acfb
4fc6e7c
ba3a62a
fda93f3
9aac2b7
acb56ab
159627c
59963dc
d104a88
cd793b9
e85b0d2
fc1bcbc
31b0dea
621e0e3
02e06e9
88c6ae5
c52cdcc
41c0161
f5021b1
04c043e
338d8f8
7bd992d
dfcd442
1d3f87b
287e630
e854c93
a5c948d
f007b80
fc39af1
527661b
2114cc5
b2340c6
f6a61ac
58d5eeb
e829ac9
d8bd836
6044965
3a1f025
2c957e7
ec840ba
5477377
3760e72
fc7d138
106bc00
32084a4
ef4bd59
d1dcaca
aa76282
105d098
4f2d4ad
30182c1
4cae152
95a8ac5
4e3e2f9
093b816
f6a02bc
76a8b92
7422282
bb009da
29d80b8
1a25993
8febf13
93537f8
59ccdf9
76af029
4eaeb23
b77bea4
654faa3
eac418a
7c9e2b2
13b869e
cbb959c
c7f49af
de07aad
ba1de00
9a4ca86
6004a7a
367c40a
0a76b25
30dae74
5d0e412
d925f53
7203115
c264674
7a5fc4b
c92c5d1
cfc125c
9bfa181
68ca6cb
d51093c
0c8e8d0
f754c36
6a35ca8
f365821
12f92e9
ccaeaa1
7b43227
df5c36e
7751925
326f7ea
13a1f2e
7512c0e
bdef29a
9eb0888
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| ### Fixed | ||
|
|
||
| - Project history now labels the actual source-post or document time basis, | ||
| keeps evidence-free responsibility gaps unknown, shows a loading status while | ||
| history is fetched, and uses the Stylelint-compatible `currentcolor` token. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| # 2.23.0 Global Ask knowledge cutoff | ||
|
|
||
| Ask Agent can name a dated question. Retrieval keeps only source posts | ||
| that existed by that clock and matches the covering | ||
| `source_post_revision` text. A missing historical body is an explicit | ||
| limitation. Live queries stay live-only. | ||
|
|
||
| The Buyer now renders each missing historical-body limitation in a partial | ||
| answer. Commitment-derived ticket creation also enforces owning-post write | ||
| authorization before calling contextual-orchestrator. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -23,7 +23,7 @@ | |
| import json | ||
| from contextlib import asynccontextmanager | ||
| from dataclasses import asdict | ||
| from datetime import datetime | ||
| from datetime import datetime, timezone | ||
| from typing import Any, Literal | ||
| from uuid import UUID | ||
|
|
||
|
|
@@ -73,8 +73,12 @@ | |
| ChatSourceDocument, | ||
| ContextualOrchestratorPostChatClient, | ||
| NullPostChatClient, | ||
| ask_grounding_status, | ||
| ask_next_action, | ||
| cited_post_citations, | ||
| cited_post_evidence, | ||
| cited_post_summaries, | ||
| historical_body_limitations, | ||
| render_global_ask_context, | ||
| ) | ||
| from lineageweave.post_content_normalization import normalize_post_body | ||
|
|
@@ -187,6 +191,16 @@ | |
| require_summary_source_body, | ||
| ) | ||
| from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL | ||
| from backend.app.project_history import ( | ||
| PROJECT_HISTORY_DEFAULT_LIMIT, | ||
| PROJECT_HISTORY_MAXIMUM_LIMIT, | ||
| PROJECT_INDEX_DEFAULT_LIMIT, | ||
| PROJECT_INDEX_MAXIMUM_LIMIT, | ||
| ProjectHistoryNotFound, | ||
| fetch_project_history_index, | ||
| fetch_project_history_projection, | ||
| ) | ||
| from lineageweave.project_history import normalize_project_key | ||
| from backend.app.demo_scope import ( | ||
| fetch_demo_corporate_entity_ids, | ||
| has_real_source_context, | ||
|
|
@@ -259,6 +273,22 @@ def _require_post_admin(account: CurrentAccount) -> None: | |
| raise HTTPException(status.HTTP_403_FORBIDDEN, "account lacks the post_admin permission") | ||
|
|
||
|
|
||
| def _require_ticket_post_access(account: CurrentAccount, post: asyncpg.Record) -> None: | ||
| """Require ticket mutation access to the owning post, not visibility alone. | ||
|
|
||
| ``post_admin`` is necessary but intentionally not sufficient: a public post | ||
| can be read by every account, while ticket state is still a write to the | ||
| authoring account's corporate work area. | ||
| """ | ||
| is_author = str(post["author_account_id"]) == account.user_account_id | ||
| is_affiliated = str(post["corporate_entity_id"]) in account.corporate_entity_ids | ||
| if not (is_author or is_affiliated): | ||
| raise HTTPException( | ||
| status.HTTP_403_FORBIDDEN, | ||
| "account is not authorized to modify tickets on this post", | ||
| ) | ||
|
Comment on lines
+276
to
+289
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📝 Info: Ticket mutation authorization correctly resolves owning post before enforcing
Was this helpful? React with 👍 or 👎 to provide feedback. |
||
|
|
||
|
devin-ai-integration[bot] marked this conversation as resolved.
|
||
|
|
||
| def _keyman_extraction_client(): | ||
| """Live orchestrator client when configured; otherwise the unavailable null.""" | ||
| settings = load_settings() | ||
|
|
@@ -2637,6 +2667,7 @@ class GlobalAskRequest(BaseModel): | |
|
|
||
| question: str | ||
| session_id: str | None = None | ||
| knowledge_cutoff: str | None = None | ||
|
|
||
|
|
||
| def global_ask_timeline(sources: list[ChatSourceDocument]) -> list[dict[str, str | None]]: | ||
|
|
@@ -2772,6 +2803,15 @@ async def ask_agent( | |
| UUID(request.session_id) | ||
| except ValueError: | ||
| raise HTTPException(status.HTTP_404_NOT_FOUND, "Global Ask session not found") from None | ||
| knowledge_cutoff = None | ||
| if request.knowledge_cutoff is not None and request.knowledge_cutoff.strip(): | ||
| try: | ||
| knowledge_cutoff = parse_as_of_clock(request.knowledge_cutoff) | ||
| except ValueError as exc: | ||
| raise HTTPException( | ||
| status.HTTP_422_UNPROCESSABLE_ENTITY, | ||
| "knowledge_cutoff must be an ISO-8601 timestamp", | ||
| ) from exc | ||
| client = _post_chat_client() | ||
| if not client.available: | ||
| raise HTTPException( | ||
|
|
@@ -2790,6 +2830,7 @@ async def ask_agent( | |
| lambda row: _can_see_post(account, row), | ||
| account.corporate_entity_ids, | ||
| question=question, | ||
| knowledge_cutoff=knowledge_cutoff, | ||
| ) | ||
| if conversation.compress_turns: | ||
| compressor = getattr(client, "compress_context", None) | ||
|
|
@@ -2821,7 +2862,11 @@ async def ask_agent( | |
| conversation.summary, | ||
| conversation.recent_turns, | ||
| ) | ||
| if not sources: | ||
| grounding_status = ask_grounding_status(sources, knowledge_cutoff) | ||
| limitations = historical_body_limitations(sources) | ||
| cutoff_text = knowledge_cutoff.isoformat() if knowledge_cutoff is not None else None | ||
| llm_sources = [source for source in sources if not source.historical_body_unavailable] | ||
| if not llm_sources: | ||
| async with pool.acquire() as conn: | ||
| await persist_global_ask_turn(conn, conversation.session_id, question, "", ()) | ||
| await publish_operation_event( | ||
|
|
@@ -2834,17 +2879,24 @@ async def ask_agent( | |
| "session_id": conversation.session_id, | ||
| "answer_text": "", | ||
| "cited_post_ids": [], | ||
| "cited_posts": [], | ||
| "source_post_ids": [], | ||
| "cited_posts": cited_post_citations(sources, [source.post_id for source in sources]), | ||
| "source_post_ids": [source.post_id for source in sources], | ||
| "cited_post_evidence": [], | ||
| "timeline": [], | ||
| "next_action": "No authorized source posts are available for this question.", | ||
| "timeline": global_ask_timeline(sources), | ||
| "knowledge_cutoff": cutoff_text, | ||
| "grounding_status": grounding_status, | ||
| "limitations": limitations, | ||
| "next_action": ask_next_action( | ||
| grounding_status, | ||
| has_sources=bool(sources), | ||
| has_retained_bodies=bool(llm_sources), | ||
| ), | ||
| } | ||
| try: | ||
| answer = await asyncio.to_thread( | ||
| client.answer, | ||
| question, | ||
| sources, | ||
| llm_sources, | ||
| conversation_context=conversation_context, | ||
| ) | ||
| except (HttpClientError, KeyError, OSError, RuntimeError, ValueError) as exc: | ||
|
|
@@ -2876,10 +2928,22 @@ async def ask_agent( | |
| "session_id": conversation.session_id, | ||
| "answer_text": answer.answer_text, | ||
| "cited_post_ids": cited_ids, | ||
| "cited_posts": cited_post_summaries(sources, cited_ids), | ||
| "cited_post_evidence": cited_post_evidence(sources, cited_ids), | ||
| "cited_posts": cited_post_citations(llm_sources, cited_ids), | ||
| "cited_post_evidence": cited_post_evidence(llm_sources, cited_ids), | ||
| # The timeline is the complete authorized retrieval boundary. A | ||
| # source without a retained cutoff body is still a real timeline | ||
| # event and must remain navigable, even though it is excluded from | ||
| # the LLM evidence bundle. | ||
| "source_post_ids": [source.post_id for source in sources], | ||
| "timeline": global_ask_timeline(sources), | ||
| "knowledge_cutoff": cutoff_text, | ||
| "grounding_status": grounding_status, | ||
| "limitations": limitations, | ||
| "next_action": ask_next_action( | ||
| grounding_status, | ||
| has_sources=True, | ||
| has_retained_bodies=bool(llm_sources), | ||
| ), | ||
| } | ||
|
|
||
|
|
||
|
|
@@ -2978,7 +3042,8 @@ async def create_post_ticket( | |
| a ticket is a write action, same discipline as extract-keymen. | ||
| """ | ||
| _require_post_admin(account) | ||
| await _load_visible_post(post_id, account, pool) | ||
| post = await _load_visible_post(post_id, account, pool) | ||
| _require_ticket_post_access(account, post) | ||
| async with pool.acquire() as conn: | ||
| try: | ||
| ticket = await create_ticket( | ||
|
|
@@ -3039,7 +3104,8 @@ async def patch_ticket( | |
| post_id = await fetch_ticket_post_id(conn, issue_ticket_id) | ||
| if post_id is None: | ||
| raise HTTPException(status.HTTP_404_NOT_FOUND, "ticket not found") | ||
| await _load_visible_post(post_id, account, pool) | ||
| post = await _load_visible_post(post_id, account, pool) | ||
| _require_ticket_post_access(account, post) | ||
| async with pool.acquire() as conn: | ||
| try: | ||
| ticket = await update_ticket( | ||
|
|
@@ -3100,6 +3166,7 @@ async def derive_post_commitment( | |
| """ | ||
| _require_post_admin(account) | ||
| post = await _load_visible_post(post_id, account, pool) | ||
| _require_ticket_post_access(account, post) | ||
| post_metadata = build_post_llm_metadata(post_id, post) | ||
| with use_llm_metadata(post_metadata): | ||
| client = _commitment_extraction_client() | ||
|
|
@@ -3371,6 +3438,76 @@ async def read_calendar( | |
| } | ||
|
|
||
|
|
||
| @app.get("/api/project-history/projects") | ||
| async def read_project_history_projects( | ||
| limit: int = Query(PROJECT_INDEX_DEFAULT_LIMIT, ge=1, le=PROJECT_INDEX_MAXIMUM_LIMIT), | ||
| account: CurrentAccount = Depends(get_current_account), | ||
| pool: asyncpg.Pool = Depends(get_pool), | ||
| ) -> dict[str, Any]: | ||
| """Return exact project identities available to the signed-in buyer.""" | ||
|
|
||
| _require_post_read(account) | ||
| knowledge_cutoff = datetime.now(timezone.utc) | ||
| async with pool.acquire() as conn: | ||
| return await fetch_project_history_index( | ||
| conn, | ||
| knowledge_cutoff=knowledge_cutoff, | ||
| corporate_entity_ids=list(account.corporate_entity_ids), | ||
| limit=limit, | ||
| ) | ||
|
|
||
|
|
||
| @app.get("/api/project-history") | ||
| async def read_project_history( | ||
| project_key: str = Query(..., min_length=1), | ||
| focus_post_id: str | None = Query(None), | ||
| knowledge_cutoff: str | None = Query(None), | ||
| limit: int = Query(PROJECT_HISTORY_DEFAULT_LIMIT, ge=1, le=PROJECT_HISTORY_MAXIMUM_LIMIT), | ||
| account: CurrentAccount = Depends(get_current_account), | ||
| pool: asyncpg.Pool = Depends(get_pool), | ||
| ) -> dict[str, Any]: | ||
| """Return one exact, authorized project history for the Buyer timeline.""" | ||
|
|
||
| _require_post_read(account) | ||
| try: | ||
| normalized_project_key = normalize_project_key(project_key) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📝 Info: Validated normalized project key is discarded
Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| except ValueError as exc: | ||
| raise HTTPException( | ||
| status.HTTP_422_UNPROCESSABLE_ENTITY, | ||
| "project_key must contain a non-empty exact identity", | ||
| ) from exc | ||
| if focus_post_id is not None: | ||
| try: | ||
| UUID(focus_post_id) | ||
| except ValueError as exc: | ||
| raise HTTPException( | ||
| status.HTTP_422_UNPROCESSABLE_ENTITY, | ||
| "focus_post_id must be a UUID", | ||
| ) from exc | ||
| if knowledge_cutoff is None: | ||
| cutoff = datetime.now(timezone.utc) | ||
| else: | ||
| try: | ||
| cutoff = parse_as_of_clock(knowledge_cutoff) | ||
| except ValueError as exc: | ||
| raise HTTPException( | ||
| status.HTTP_422_UNPROCESSABLE_ENTITY, | ||
| "knowledge_cutoff must be an ISO-8601 timestamp", | ||
| ) from exc | ||
| async with pool.acquire() as conn: | ||
| try: | ||
| return await fetch_project_history_projection( | ||
| conn, | ||
| project_key=project_key, | ||
| focus_post_id=focus_post_id, | ||
| knowledge_cutoff=cutoff, | ||
| corporate_entity_ids=list(account.corporate_entity_ids), | ||
| limit=limit, | ||
| ) | ||
| except ProjectHistoryNotFound as exc: | ||
| raise HTTPException(status.HTTP_404_NOT_FOUND, "project history not found") from exc | ||
|
|
||
|
|
||
| @app.get("/api/rankings") | ||
| async def read_rankings( | ||
| account: CurrentAccount = Depends(get_current_account), | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📝 Info: Duplicated sentence in ARCHITECTURE.md
The rewritten Phase-4 frontend paragraph repeats "The full detail popup includes Korean" twice (ARCHITECTURE.md:287-289), a documentation editing artifact from splicing in the ADR 0135 sentence.
Was this helpful? React with 👍 or 👎 to provide feedback.