Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,12 @@ and are not a leftover score. Complete-case coverage (ADR 0168) persists to
`report_leftover_map_coverage` and captions the pair list with how
many scored posts entered the map.

Global Ask relative-time filters (ADR 0150 / 0202) bind to
`source_post.event_occurred_at` and fall back to `created_at` only
when the event instant is missing. Cited evidence names **Time
axis** so the reader can open that post and see which clock
matched. Do not invent an event date or a theta.

Period leftover pairs (ADR 0017 / 0018 / 0048 / 0049 / 0149) are computed in
`lineageweave/leftover_pairs.py` from the residual after a real
GRM/GPCM score, never invented. Missing cells stay out of the
Expand Down
2 changes: 2 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ flowchart LR
| `post_summary.py` | Pluggable LLM Korean summary + key events + R&R derivation for a post |
| `post_chat.py` | Pluggable in-popup chat's reason-and-cite step (retrieve step lives in `backend/app/post_chat_ingestion.py`) |
| `commitment_extraction.py` | Pluggable LLM derivation of a customer commitment (promise + deadline) from a post; `Null` default, `ContextualOrchestrator` real impl |
| `temporal_expressions.py` | Pure Korean relative-time resolver for Global Ask (ADR 0150) |
| `ask_time_axis.py` | Event-time vs ingestion-time clock choice for that window (ADR 0202) |
| `ontology.py` | Loads `docs/ontology/lineageweave-kg.ttl`, the formal OWL 2/RDFS/SKOS vocabulary for the Knowledge Graph's node/edge types (ADR 0004) |
| `ontology_neighborhood.py` | Bounded typed ontology/provenance neighborhood (ADR 0184); PostgreSQL stays authoritative, OWL subclass is not an instance edge |
| `ontology_source_cursor.py` | Opaque HMAC source-window continuation (ADR 0124); keyset pagination, never OFFSET |
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.d/2.17.0-ask-event-time-filter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Global Ask relative-time filters bind to event_occurred_at with a
created_at fallback and name the matching time axis on cited evidence
(ADR 0202 / #569).
Comment thread
seonghobae marked this conversation as resolved.
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,15 @@ All notable changes to this project are documented here. Format follows
session token is removed, hides live refocus on static catalog snapshots,
and looks up corporate-parent visibility independently of the child. OWL-Time
is cited as a W3C Candidate Recommendation Draft.
- Global Ask relative-time filters bind to `event_occurred_at` (the
source-system event instant) and fall back to `created_at` only when
that event clock is missing (ADR 0202 / #569). After `make seed`,
leftover closest/farthest pairs still sit above the member list; a
click still opens that post. Ask **어제 무슨 일이 있었나요?** on
bulk-imported fixtures that share one ingest day keeps the post whose
event fell yesterday, drops last week's event, and names **Time axis**
on the cited evidence. Open that cited post to read which clock
matched. Never invent a theta or an event date.

## [2.15.0] - 2026-08-25

Expand Down
57 changes: 0 additions & 57 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,60 +54,3 @@ migration replay (ADR 0166), docstring coverage, and the measurement
boundary are all stated in [AGENTS.md](AGENTS.md) -- read it before
changing code, tests, or runtime policy rather than restating anything
here.
=======
period-report run on the same snapshot (ADR 0013 / ADR 0024). The TEPP path goes through `tepp_client`. A missing
transport or an unused accepted envelope is Failed
(`tepp_not_available` / `tepp_result_not_persisted`). Do not invent a
theta or a local psychometric substitute. The home list caption stays
`kind · status · entity`; the machine failure code is detail-only
(ADR 0014). Open a Failed TEPP row, then connect a live TEPP
transport. A failed lineage row retries reconstruction -- it does not
mention TEPP. A failed period-report row rebuilds the report. A
pending TEPP row does not claim a calibrated measurement. A pending
lineage row says reconstruction has not started yet.
Digest prefixes stay audible; hover a prefix to read the full digest.
Opening a cutoff title shows the live post. Titles marked updated
after cutoff were rewritten after the run; the opened body names
both clocks and shows **Body this run knew** beside the live
rewrite. Compare those two texts before treating the live body as
reconstructed evidence (ADR 0016 / 0025).
`POST /api/analysis-runs` records Pending lineage only on an
authorized cutoff capture (ADR 0017). TEPP and period-report kinds
are 422. The Request button waits until affiliated corps load; choose
a corp if the token walks more than one. `POST /api/analysis-runs/{id}/start`
commits Running plus a durable outbox row, then reconstructs that
frozen cutoff bag (ADR 0021 / ADR 0023) or submits TEPP through
`tepp_client` (ADR 0022). A missing transport or unused accepted
envelope is Failed. Failed TEPP is terminal — connect a TEPP
transport from that Failed row. Create does not invent a Pending
TEPP row. Do not invent a theta. Hover the Result prefix to read
the parent-choice digest.
After `make seed`, open **Period report · Succeeded · Demo Corp**,
then **Open period report 2026-W02**. The home week is already
2026-W02, so the grouping comparison strip lands on Demo Corp. Report
grouping is Corporate entity and Demo Corp is current. The focused
chip name contains `Corporate entity: Demo Corp` and the persisted
mean θ. The period-report panel says Demo Corp is the opened grouping
and to read its mean θ and member posts, then open a post. Those
members land immediately under that next action, ahead of Other Corp
and the week strip. After `make seed`, leftover closest/farthest pairs
sit above the member list with leftover-map rank (rank 0 names no
leftover structure) and unexplained leftover `U` next to leftover-map
distance `d`. Leftover-map axis share badges name Gabriel inertia
of axes 1 and 2; open a leftover pair to read the post–criterion cell.
The shares do not invent a leftover score. Opening Public post names the next action: read
Event Lineage, Keyman, and evaluation on that post. The popup Event
Lineage DAG marks that post current. After that current node, the
popup names Keyman and evaluation as the next read. After landed
evaluation, the popup names the first Keyman as the next read. After
landed Ada West related, the popup names the first related node as
the next read. After that next action, the popup lands Priya Nair
related nodes. After those related nodes land, the popup names Ask
about this lineage as the next read. After that next action, the
popup lands Ask about this lineage. After landed chat, the popup names
the first Ask. After that next action, the popup lands the first Ask
answer. After landed first Ask answer, the popup names the first
cited source. After that next action, the popup lands the first cited
evidence. Changing the week first still
focuses the report period field. Mean θ stays on the period-report
panel.
57 changes: 41 additions & 16 deletions backend/app/post_chat_ingestion.py
Comment thread
seonghobae marked this conversation as resolved.
Comment thread
seonghobae marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@

import asyncpg

from lineageweave.ask_time_axis import row_matches_time_range, time_axis_evidence_fact
from lineageweave.image_content import ImageContentClient, NullImageContentClient
from lineageweave.knowledge_graph import (
NODE_POST,
Expand Down Expand Up @@ -406,9 +407,11 @@ async def gather_global_chat_sources(

When `question` contains a Korean relative-time expression ("어제",
"작년 이맘때쯤", "3일 전", ...; see `lineageweave.temporal_expressions`),
the final candidate set is also bounded to posts whose `created_at`
falls in the resolved date range -- an unbounded expression ("언젠가")
or no expression at all applies no date filter.
the final candidate set is bounded to posts whose event instant
(`event_occurred_at`, falling back to `created_at`) falls in the
resolved Seoul date range -- an unbounded expression ("언젠가")
or no expression at all applies no date filter. Cited sources name
which clock matched (ADR 0202).

The source set is intentionally bounded until retrieval/reranking is
needed for a much larger corpus; every selected body still uses the same
Expand All @@ -419,8 +422,9 @@ async def gather_global_chat_sources(
if vision_client is None:
vision_client = NullImageContentClient()
# A relative-time expression ("어제", "작년 이맘때쯤", ...) narrows the
# candidate window by created_at below; it must not also become a
# near-meaningless literal keyword search term (see TEMPORAL_STOPWORDS).
# candidate window by event time (fallback: created_at) below; it must
# not also become a near-meaningless literal keyword search term
# (see TEMPORAL_STOPWORDS).
resolved_time_range = resolve_korean_relative_time(
question or "", today=today or _seoul_today()
)
Expand Down Expand Up @@ -475,24 +479,34 @@ async def gather_global_chat_sources(
"""
select post_id, matched_in
from (
(select post_id, created_at, 'title' as matched_in
(select post_id, coalesce(event_occurred_at, created_at) as event_clock,
'title' as matched_in
from source_post
where post_title ilike '%' || $1 || '%'
and ($2::date is null or (coalesce(event_occurred_at, created_at) at time zone 'Asia/Seoul')::date >= $2)
and ($3::date is null or (coalesce(event_occurred_at, created_at) at time zone 'Asia/Seoul')::date <= $3)
limit 32)
union all
(select post_id, created_at, 'body' as matched_in
(select post_id, coalesce(event_occurred_at, created_at) as event_clock,
'body' as matched_in
from source_post
where lower(left(source_post_search_text(post_body), 16384))
like '%' || lower($1) || '%'
and ($2::date is null or (coalesce(event_occurred_at, created_at) at time zone 'Asia/Seoul')::date >= $2)
and ($3::date is null or (coalesce(event_occurred_at, created_at) at time zone 'Asia/Seoul')::date <= $3)
limit 32)
union all
(select post_id, created_at, 'body' as matched_in
(select post_id, coalesce(event_occurred_at, created_at) as event_clock,
'body' as matched_in
from source_post
where to_tsvector('simple', source_post_search_text(post_body))
@@ plainto_tsquery('simple', $1)
and ($2::date is null or (coalesce(event_occurred_at, created_at) at time zone 'Asia/Seoul')::date >= $2)
and ($3::date is null or (coalesce(event_occurred_at, created_at) at time zone 'Asia/Seoul')::date <= $3)
limit 32)
union all
(select post_id, created_at, 'source_field' as matched_in
(select post_id, coalesce(event_occurred_at, created_at) as event_clock,
'source_field' as matched_in
from source_post
where concat_ws(' ', source_system_code, source_record_key,
source_author_code, source_author_name,
Expand All @@ -502,12 +516,16 @@ async def gather_global_chat_sources(
source_customer_code, source_customer_name,
source_project_code, source_project_name)
ilike '%' || $1 || '%'
and ($2::date is null or (coalesce(event_occurred_at, created_at) at time zone 'Asia/Seoul')::date >= $2)
and ($3::date is null or (coalesce(event_occurred_at, created_at) at time zone 'Asia/Seoul')::date <= $3)
limit 32)
) matches
order by created_at desc, post_id desc
order by event_clock desc, post_id desc
limit 32
""",
term,
resolved_time_range[0] if resolved_time_range else None,
resolved_time_range[1] if resolved_time_range else None,
)
for row in candidate_rows:
post_id = str(row["post_id"])
Expand Down Expand Up @@ -551,14 +569,15 @@ async def gather_global_chat_sources(
source_company_code, source_company_name, source_process_unit_code,
source_process_unit_name, source_sales_pool_code, source_sales_pool_name,
source_customer_code, source_customer_name,
source_project_code, source_project_name
source_project_code, source_project_name,
created_at, event_occurred_at
from source_post
where (visibility_code = 'public'
or corporate_entity_id::text = any($1::text[]))
and ($4::date is null or (created_at at time zone 'Asia/Seoul')::date >= $4)
and ($5::date is null or (created_at at time zone 'Asia/Seoul')::date <= $5)
and ($4::date is null or (coalesce(event_occurred_at, created_at) at time zone 'Asia/Seoul')::date >= $4)
and ($5::date is null or (coalesce(event_occurred_at, created_at) at time zone 'Asia/Seoul')::date <= $5)
order by array_position($2::uuid[], post_id) nulls last,
created_at desc, post_id desc
coalesce(event_occurred_at, created_at) desc, post_id desc
limit $3
""",
list(authorized_corporate_entity_ids),
Expand All @@ -567,11 +586,16 @@ async def gather_global_chat_sources(
resolved_time_range[0] if resolved_time_range else None,
resolved_time_range[1] if resolved_time_range else None,
)
Comment thread
seonghobae marked this conversation as resolved.
visible_rows = [row for row in rows if can_see_post(row)][:limit]
visible_rows = [
row
for row in rows
if can_see_post(row) and row_matches_time_range(row, resolved_time_range)
][:limit]
Comment thread
seonghobae marked this conversation as resolved.
Comment thread
seonghobae marked this conversation as resolved.
visible_ids = [str(row["post_id"]) for row in visible_rows]
anchor_is_visible = lineage_anchor_id in visible_ids
semantic_facts = await _semantic_facts_for_posts(conn, visible_ids)
graph_facts = (await _graph_facts_for_posts(conn, visible_ids))[:16]
time_filter_active = resolved_time_range is not None
sources: list[ChatSourceDocument] = []
for index, row in enumerate(visible_rows):
normalized_body = await _normalize_post_body_text(row["post_body"], vision_client)
Expand All @@ -594,7 +618,8 @@ async def gather_global_chat_sources(
graph_facts=graph_facts if index == 0 else (),
evidence_facts=_source_hint_facts(row)
+ semantic_facts.get(post_id, ())
+ lineage_fact,
+ lineage_fact
+ time_axis_evidence_fact(row, time_filter_active=time_filter_active),
)
)
return sources
Expand Down
6 changes: 6 additions & 0 deletions backend/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,11 @@
/ "migrations"
/ "0182_report_leftover_map_unexplained.sql"
)
_EVENT_OCCURRED_AT_MIGRATION = (
Path(__file__).resolve().parents[2]
/ "migrations"
/ "0183_source_post_event_occurred_at.sql"
)


def _postgres_available() -> bool:
Expand Down Expand Up @@ -303,6 +308,7 @@ def seeded_db(demo_analyst_token):
cur.execute(_LEFTOVER_MAP_RANK_MIGRATION.read_text())
cur.execute(_LEFTOVER_MAP_COVERAGE_MIGRATION.read_text())
cur.execute(_GLOBAL_ASK_JOB_MIGRATION.read_text())
cur.execute(_EVENT_OCCURRED_AT_MIGRATION.read_text())
cur.execute(_LEFTOVER_MAP_AXIS_MIGRATION.read_text())
cur.execute(_LEFTOVER_MAP_UNEXPLAINED_MIGRATION.read_text())
cur.execute(
Expand Down
15 changes: 8 additions & 7 deletions docs/adr/0150-korean-relative-time-retrieval.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

- Status: Accepted
- Date: 2026-08-22
- Related: [0047](0047-global-ask-semantic-retrieval.md), [0090](0090-global-ask-lineage-timeline-expansion.md)
- Related: [0047](0047-global-ask-semantic-retrieval.md), [0090](0090-global-ask-lineage-timeline-expansion.md), [0202](0202-ask-event-time-filter.md)

## Context

Expand All @@ -27,12 +27,13 @@ bound -- the reader has explicitly declined to name one, which is the same
retrieval behavior as finding no expression at all.

`gather_global_chat_sources` applies the resolved window as an additional
`created_at` bound on its final ABAC-filtered candidate query, additive to
the existing keyword-match ranking -- it narrows the already-ranked
candidate set, it does not replace ranking with a date filter. Matched
temporal literals are excluded from keyword-term extraction
(`TEMPORAL_STOPWORDS`) so a resolved expression does not also become a
near-meaningless literal search term.
event-time bound on its final ABAC-filtered candidate query (ADR 0202:
`coalesce(event_occurred_at, created_at)`), additive to the existing
keyword-match ranking -- it narrows the already-ranked candidate set, it
does not replace ranking with a date filter. Cited sources name which
clock matched. Matched temporal literals are excluded from keyword-term
extraction (`TEMPORAL_STOPWORDS`) so a resolved expression does not also
become a near-meaningless literal search term.

## Considered alternatives

Expand Down
Loading
Loading