Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
e365664
fix: prioritize evidence-bound operations backfill (#716)
seonghobae Aug 27, 2026
40d953f
docs: refresh dashboard repair snapshot
Aug 28, 2026
7db6af6
fix: isolate acceptance profiles and research copy
Aug 28, 2026
f5706e8
revert: keep measured compose profile boundary
Aug 28, 2026
2e558d3
docs: trace final dashboard repair review
Aug 28, 2026
afa8f4b
fix(frontend): localize operations dashboard copy
Aug 28, 2026
f68a0b2
test(frontend): verify dashboard locale switching
Aug 28, 2026
5138c7c
fix(frontend): localize dashboard code labels
Aug 28, 2026
1d8fcc7
fix(frontend): localize workspace navigation
Aug 28, 2026
5ea18ae
docs: refresh TEPP estimator evidence
Aug 28, 2026
09bf6ec
fix: stabilize source evidence response races
Aug 28, 2026
fd7de92
fix(frontend): remove mixed dashboard locale copy
Aug 28, 2026
959695d
fix(dashboard): close measurement and runtime acceptance gaps
Aug 28, 2026
e857c78
docs(gap): refresh exact repair snapshot
Aug 28, 2026
60150d8
fix(worker): continue bounded durable backfill
Aug 28, 2026
b2b46f4
docs(gap): bind durable continuation evidence
Aug 28, 2026
587a01a
fix(orchestrator): retain buildable Rust runtime pin
Aug 28, 2026
94cb65d
fix: keep externalized math boundaries fail closed
Aug 28, 2026
ddb3235
fix(seed): omit unavailable calibrated lineage
Aug 28, 2026
3d38f48
fix(frontend): keep mobile dashboard metrics readable
Aug 28, 2026
a72cd41
merge: restack dashboard repair onto current parent
Aug 28, 2026
0070cd2
fix: keep external lineage inference fail closed
Aug 28, 2026
e19ca90
merge: synchronize reviewed dashboard parent
Aug 28, 2026
6f9b328
test: align repair stack with owner math boundary
Aug 28, 2026
bc8b1f3
fix(frontend): restore repair-stack contracts
Aug 28, 2026
3ddee07
test(frontend): align locale-aware navigation
Aug 28, 2026
4982455
docs: correct commercial automation gap evidence
Aug 28, 2026
572ef39
merge: synchronize final dashboard parent repair
Aug 28, 2026
f27f427
docs: refresh exact repair-stack snapshot
Aug 28, 2026
a8b9199
docs: align source research citations with ADR 0268
Aug 28, 2026
df22c62
feat: expose post source research in detail view
Aug 28, 2026
94927d1
fix: keep private source research actions unavailable
Aug 28, 2026
b87b186
fix: preserve public research retry after citation load failure
Aug 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ MCP_RATE_LIMIT_WINDOW_SECONDS=
# running contextual-orchestrator to turn the channels on.
ORCHESTRATOR_BASE_URL=
ORCHESTRATOR_API_KEY=
SOURCE_RESEARCH_MAXIMUM_LEADS=
SOURCE_RESEARCH_MAXIMUM_RESULTS=

# GitHub workflows inject the canonical provider names from masked secrets.
# Non-GitHub Compose runs also accept the operator's ~/.env compatibility
Expand Down
14 changes: 12 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,8 +196,10 @@ contextual-orchestrator owns model discovery and selection.

`NullEmbeddingClient`, `NullAdjudicationClient`,
`NullKeymanExtractionClient`, `NullEntityRelationshipClient`,
`NullPostSummaryClient`, `NullPostChatClient`, and
`NullCommitmentExtractionClient` (and any new channel client you add)
`NullPostSummaryClient`, `NullPostChatClient`,
`NullCommitmentExtractionClient`, `NullRelationVerificationClient`,
`NullClaimVerificationClient`, and `NullSourceResearchClient`
(and any new channel client you add)
must set `available = False` and make their channel dropped +
renormalized (`reconstruct.active_weights`), never silently return a
placeholder score, invented Keyman, guessed relationship, fabricated
Expand All @@ -210,6 +212,14 @@ adjudication does -- never a raw LLM API. Demo TEPP seed goes through
envelope is Failed (`tepp_not_available` / `tepp_result_not_persisted`),
never a fabricated theta or a local psychometric substitute.

Public source-reference research (ADR 0268) is a post-scoped write action
on existing semantic units or image regions. Only `visibility_code=public`
posts may send lead text to SearXNG or retrieve a result URL. Private posts
fail closed without egress. Redirects and non-global targets are rejected.
Unavailable search, retrieval, or adjudication is `research_unavailable`,
never a fabricated supported/refuted judgment. Global Ask public
verification (ADR 0215) still never fetches result URLs.

The lineage `text` channel follows [ADR 0190](docs/adr/0190-lineage-text-channel-embedding-swap.md):
when an embedding provider is configured, `reconstruct()` precomputes
batched label embeddings once per reconstruction and scores cosine
Expand Down
13 changes: 13 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -870,6 +870,19 @@ against a deliberately fabricated one in the same request, asserting
the former comes back `verify_corroborated` with a real evidence URL
and the latter `verify_uncorroborated` with none.

## Phase 6e: post-scoped source-reference research

Issue #611's remaining ADR 0133 criterion is a different workflow from
relation verification and from Global Ask snippet verification (ADR 0215).
A public post may send an existing semantic unit or image-region excerpt
to self-hosted SearXNG, retrieve one cited public page under SSRF and
redirect rejection, and ask contextual-orchestrator to judge in
`mode="verify"`. Private posts fail closed without egress. Citations
persist to `source_research_citation` (migration 0236, ADR 0268). The
reader next action is to open the cited public resource and compare it
with the highlighted passage or image detail. Global Ask still never
fetches result URLs.

## Phase 7: R&R's named actor is a PROV-O Agent, not always a person

`post_summary.py`'s R&R extraction forced every named actor into a
Expand Down
17 changes: 17 additions & 0 deletions CHANGELOG.d/2.19.0-post-scoped-source-reference-research.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# 2.19.0 — Post-scoped source-reference research

## Added

- Public posts can research a highlighted passage or image detail against a
cited public page (ADR 0268, remaining ADR 0133 / issue #611). The workflow
reuses self-hosted SearXNG, retrieves one public HTTP(S) target with
redirects disabled and non-global addresses rejected, and judges through
contextual-orchestrator `mode=verify`. Private posts fail closed without
egress. Deployments must set both source-research resource budgets explicitly;
otherwise the channel remains unavailable. Citations persist in 3NF
`source_research_citation`.
- Reader next action: open the cited public resource, then compare it with
the highlighted passage or image detail. Supported or refuted judgments
without a cited URL downgrade to not enough information. Missing search,
retrieval, or adjudication is `research_unavailable`, never a fabricated
score.
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

# Keep provider credentials outside the repository. Compose interpolation must
# read the same home env file as the orchestrator container's env_file.
COMPOSE := docker compose --env-file "$$HOME/.env"
COMPOSE := COMPOSE_FILE=docker-compose.yml docker compose --env-file "$$HOME/.env"

up:
$(COMPOSE) up -d
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,11 @@ docker compose --profile mcp up mcp
# Streamable HTTP resource: http://localhost:18001/mcp
```

For client initialization, tool arguments, durable status handling, and quota
recovery, see the [MCP manual](docs/manuals/mcp-manual.md). Workspace users can
start with the [user guide](docs/manuals/user-guide.md); deployment and incident
procedures are in the [operations manual](docs/manuals/operations-manual.md).

`GET /api/posts`, `GET /api/posts/{post_id}`,
`GET /api/posts/{post_id}/keymen`, `GET /api/keymen/{person_id}/related`,
`GET /api/posts/{post_id}/affiliate-tree`,
Expand Down
2 changes: 2 additions & 0 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev --extra backend --no-editable \
&& chown -R appuser:appuser /app

ARG LINEAGEWEAVE_SOURCE_REVISION=unknown
LABEL org.opencontainers.image.revision=${LINEAGEWEAVE_SOURCE_REVISION}
USER appuser
EXPOSE 8000
CMD ["uvicorn", "backend.app.main:app", "--host", "0.0.0.0", "--port", "8000"]
8 changes: 8 additions & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ class Settings:
orchestrator_answer_timeout_seconds: float
valkey_url: str
searxng_base_url: str
source_research_maximum_leads: int | None
source_research_maximum_results: int | None
tepp_transport_url: str
tepp_api_key: str
caldav_base_url: str
Expand Down Expand Up @@ -205,6 +207,12 @@ def load_settings() -> Settings:
),
valkey_url=os.environ.get("VALKEY_URL", "redis://localhost:16379/0"),
searxng_base_url=os.environ.get("SEARXNG_BASE_URL", ""),
source_research_maximum_leads=_optional_positive_int(
"SOURCE_RESEARCH_MAXIMUM_LEADS"
),
source_research_maximum_results=_optional_positive_int(
"SOURCE_RESEARCH_MAXIMUM_RESULTS"
),
tepp_transport_url=os.environ.get("TEPP_TRANSPORT_URL", ""),
tepp_api_key=os.environ.get("TEPP_API_KEY", "").strip(),
caldav_base_url=os.environ.get("CALDAV_BASE_URL", "").strip(),
Expand Down
25 changes: 19 additions & 6 deletions backend/app/global_ask_queue.py
Comment thread
seonghobae marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
gather_global_chat_sources,
prepare_global_question_embedding,
)
from .source_research_ingestion import list_ask_source_references

GLOBAL_ASK_STREAM_KEY = "global_ask_request_stream"

Expand Down Expand Up @@ -380,6 +381,7 @@ def can_see(row: asyncpg.Record) -> bool:
"cited_events": [],
"source_post_ids": [source.post_id for source in sources],
"cited_post_evidence": [],
"cited_source_references": [],
"lineage_graph": {"nodes": [], "edges": [], "truncated": False},
"cited_post_images": [],
"external_verification_status": verification_status,
Expand Down Expand Up @@ -438,13 +440,18 @@ def can_see(row: asyncpg.Record) -> bool:
verify_external=verify_external,
client=verification_client,
)
if knowledge_cutoff is None:
async with pool.acquire() as conn:
async with pool.acquire() as conn:
if knowledge_cutoff is None:
lineage_graph = await lineage_graphs_for_posts(conn, can_see, cited_ids)
images = await cited_post_images(conn, cited_ids)
else:
lineage_graph = {"nodes": [], "edges": [], "truncated": False}
images = []
else:
lineage_graph = {"nodes": [], "edges": [], "truncated": False}
images = []
source_references = await list_ask_source_references(
conn,
cited_ids,
checked_by=knowledge_cutoff,
)
Comment thread
seonghobae marked this conversation as resolved.
Comment thread
seonghobae marked this conversation as resolved.
cited_posts = cited_post_summaries(usable_sources, cited_ids)
cited_events = cited_post_events(usable_sources, cited_ids)
cited_evidence = cited_post_evidence(usable_sources, cited_ids)
Expand All @@ -462,9 +469,15 @@ def can_see(row: asyncpg.Record) -> bool:
"cited_events": cited_events,
"cited_post_evidence": cited_evidence,
"cited_post_images": images,
"cited_source_references": source_references,
"source_post_ids": [source.post_id for source in sources],
"lineage_graph": lineage_graph,
"delivery": build_ask_delivery(answer.answer_text, cited_posts, cited_evidence),
"delivery": build_ask_delivery(
answer.answer_text,
cited_posts,
cited_evidence,
source_references,
),
"external_verification_status": verification_status,
"external_claims": [claim.to_payload() for claim in external_claims],
"next_action": next_action,
Expand Down
137 changes: 137 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,10 @@
)
from backend.app.ranking_ingestion import load_visible_ranking_posts
from backend.app.relation_verification_ingestion import verify_post_relations_from_pool
from backend.app.source_research_ingestion import (
list_source_research_citations,
research_post_sources_from_pool,
)
from backend.app.report_ingestion import (
GROUPING_KINDS,
fetch_period_comparison,
Expand Down Expand Up @@ -269,6 +273,12 @@
NullRelationVerificationClient,
SearxngRelationVerificationClient,
)
from lineageweave.source_reference_research import (
PRIVATE_POST_UNAVAILABLE,
VISIBILITY_PUBLIC,
NullSourceResearchClient,
SearxngOrchestratedSourceResearchClient,
)
from lineageweave.semantic_hints import customer_hint_trust, format_semantic_hints
from lineageweave.semantic_query import (
ContextualOrchestratorSemanticQueryClient,
Expand Down Expand Up @@ -380,6 +390,27 @@ def _claim_verification_client_factory():
return _claim_verification_client()


def _source_research_client():
"""Return the post-scoped public-research client, or its unavailable null."""

settings = load_settings()
if not (
settings.searxng_base_url
and settings.orchestrator_base_url
and settings.orchestrator_api_key
and settings.source_research_maximum_leads is not None
and settings.source_research_maximum_results is not None
):
return NullSourceResearchClient()
return SearxngOrchestratedSourceResearchClient(
settings.searxng_base_url,
settings.orchestrator_base_url,
settings.orchestrator_api_key,
maximum_leads=settings.source_research_maximum_leads,
maximum_results=settings.source_research_maximum_results,
)


def _organization_name_resolution_client():
"""Live orchestrator client when configured; otherwise the unavailable null."""
settings = load_settings()
Expand Down Expand Up @@ -2919,6 +2950,112 @@ async def verify_post_entity_relationships(
}


@app.get("/api/posts/{post_id}/research-citations")
async def read_post_research_citations(
post_id: str,
account: CurrentAccount = Depends(get_current_account),
pool: asyncpg.Pool = Depends(get_pool),
) -> dict[str, Any]:
"""Return persisted public-research citations for this post's source leads."""

post = await _load_visible_post(post_id, account, pool)
if str(post["visibility_code"]) != VISIBILITY_PUBLIC:
return {
"post_id": str(post["post_id"]),
"visibility_code": post["visibility_code"],
"unavailable_reason": PRIVATE_POST_UNAVAILABLE,
"citations": [],
}
async with pool.acquire() as conn:
citations = await list_source_research_citations(conn, post_id)
return {
"post_id": str(post["post_id"]),
"visibility_code": post["visibility_code"],
"unavailable_reason": None,
"citations": [
{
"lead_kind_code": row["lead_kind_code"],
"lead_source_unit_id": row["lead_source_unit_id"],
"lead_image_region_id": row["lead_image_region_id"],
"lead_excerpt_text": row["lead_excerpt_text"],
"search_query_text": row["search_query_text"],
"evidence_url": row["evidence_url"],
"evidence_title_text": row["evidence_title_text"],
"evidence_excerpt_text": row["evidence_excerpt_text"],
"judgment_code": row["judgment_code"],
"rationale_text": row["rationale_text"],
"next_action_text": row["next_action_text"],
"checked_at": row["checked_at"],
}
for row in citations
],
}


@app.post("/api/posts/{post_id}/research-citations")
async def research_post_source_references(
post_id: str,
account: CurrentAccount = Depends(get_current_account),
pool: asyncpg.Pool = Depends(get_pool),
valkey: redis.Redis = Depends(get_valkey),
) -> dict[str, Any]:
"""Search and retrieve a public resource for this post's source leads.

Private posts fail closed without sending content. Gated by post_admin
because retrieval is a real external-search write action.
"""

_require_post_admin(account)
post = await _load_visible_post(post_id, account, pool)
if str(post["visibility_code"]) != VISIBILITY_PUBLIC:
return {
"post_id": str(post["post_id"]),
"visibility_code": post["visibility_code"],
"unavailable_reason": PRIVATE_POST_UNAVAILABLE,
"citations": [],
}
client = _source_research_client()
if not client.available:
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
"Public research is unavailable. Ask an administrator to enable it, "
"then try again.",
)
try:
with use_llm_metadata(build_post_llm_metadata(post_id, post)):
run = await research_post_sources_from_pool(
pool,
client,
post_id,
visibility_code=str(post["visibility_code"]),
)
except (HttpClientError, OSError, ValueError) as exc:
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
"Public research could not be completed. Try again later or review "
"this post's existing evidence.",
) from exc
except Exception as exc: # noqa: BLE001 - provider boundary is fail-closed.
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
"Public research could not be completed. Try again later or review "
"this post's existing evidence.",
) from exc
await publish_activity_event(
valkey,
post_id,
"source_research_checked",
account.user_account_id,
f"Public sources reviewed: {len(run.citations)} item(s)",
)
return {
"post_id": run.post_id,
"visibility_code": run.visibility_code,
"unavailable_reason": run.unavailable_reason,
"citations": [citation.to_payload() for citation in run.citations],
}


@app.post("/api/posts/{post_id}/extract-keymen")
async def extract_post_keymen(
post_id: str,
Expand Down
2 changes: 1 addition & 1 deletion backend/app/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ async def lifespan(_: MCPServer) -> AsyncIterator[McpAppContext]:
"lineageweave",
title="LineageWeave",
description="Authenticated provenance-bearing lineage intelligence.",
version="2.18.0",
version="2.19.0",
lifespan=lifespan,
token_verifier=token_verifier or KeyverseMcpTokenVerifier(resolved),
auth=AuthSettings(
Expand Down
3 changes: 3 additions & 0 deletions backend/app/operations_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,9 @@ async def fetch_operations_dashboard(
)
return {
"period_label": _period_label(period_start, period_end),
"period_start": period_start.isoformat() if period_start else None,
"period_end": period_end.isoformat() if period_end else None,
"period_time_axis_code": "event_occurred_at",
"total_post_count": total,
"total_event_count": int(metrics["total_event_count"]),
"external_post_count": external,
Expand Down
Loading