Commit Graph
5509 Commits
Author SHA1 Message Date
Alex 5406550bca feat(quotas): enforce user quotas on chat, agent, scheduled and webhook runs
check_usage now checks the billable user's quota on every request, before
the per-agent 24h limits, which keep applying to traffic through an agent.
Until now a request without an agent key skipped every limit. A refusal is
a 429 with Retry-After and a body naming the budget, usage, limit, the
layer the limit came from and when it resets.

Headless runs check the agent owner's quota before starting. A refused
scheduled run is recorded as budget_exceeded; a refused webhook run returns
a quota_exceeded result instead of raising, so Celery does not retry it.
2026-09-21 12:11:11 +01:00
Alex 6826313b60 feat(quotas): limit resolution and the quota service
docsgpt/quotas resolves a user's effective token and cost limits from the
policy rows that apply to them: their own override, then the most generous
allowance among their teams, then the instance default, then any defaults a
registered provider supplies. Each budget resolves on its own, and a team
counts once however many memberships the user holds in it.

QuotaService compares those limits with the user's token_usage totals over
the current QUOTA_PERIOD window (calendar-aligned, UTC, computed at read
time). It fails open, and skips the usage query for unlimited users.
2026-09-21 12:11:11 +01:00
Alex 6c42139224 fix(usage): stop double counting scheduled runs; attribute workflow node usage
sum_tokens_in_range now skips the scheduler's per-run rollup rows, whose
tokens are already on the run's per-call rows, so the per-agent 24h limit
and the admin total no longer count scheduled spend twice.

Workflow node LLMs carry the workflow agent's id, so their usage rows are
attributed to the agent instead of landing with a user id only.

usage_totals returns a user's tokens and cost since a window start, split
by interactive and agent-key traffic.
2026-09-21 12:11:11 +01:00
Alex 43fad2a865 feat(quotas): quota_policies table and a per-call cost on token_usage
Migration 0033 adds quota_policies (instance default, team per-member
allowance, user override; a token budget and a USD budget per row) and
token_usage.cost. The column is added IF NOT EXISTS so a database that
already carries it upgrades cleanly.

Every usage row now records the call's USD cost from the model catalog;
bring-your-own models are recorded at $0.
2026-09-21 12:11:11 +01:00
Alex b77561288d feat(pricing): per-million model rates and a cost module
Rename the unused *_cost_per_token capability fields to USD per 1M tokens,
add prompt-cache read/write rates, and ship list prices for the hosted
catalogs. The old per-token keys still load, scaled, with a warning.

docsgpt/pricing.py turns a call's token bins into a USD cost. Models with
no declared rate cost $0 unless QUOTA_UNPRICED_RATE_PER_MILLION is set.
2026-09-21 11:38:22 +01:00
Alex 1c1bc2538f Merge pull request #2812 from arc53-machine/feat/personal-access-tokens
feat: personal access tokens (scoped API tokens for CLI and CI/CD)
2026-09-21 10:55:33 +01:00
arc53-machine 52069e3f8a feat(frontend): regenerate access tokens; fix resource pickers inside the create modal
Each token gets a Regenerate action: a confirmation with the new expiration
(preselecting the lifetime the token was issued with), then the one-time
secret view.

The "restrict to specific resources" pickers could not be scrolled and did
not close on an outside click. Inside a Modal a non-modal popover is
portalled outside the dialog, so the dialog's scroll lock swallowed the wheel,
and Radix defers its outside-click dismissal to the document click, which
Modal stops from propagating. MultiSelect takes a `modal` prop for that case.
2026-09-21 10:32:17 +01:00
arc53-machine 91f2ec2f09 feat(pat): regenerate a token's secret and reset its expiry
POST /api/user/tokens/<id>/regenerate swaps the secret of an existing token
in place: name, scopes and restrictions stay, the old secret stops matching
at once, and the expiry is reset. The lifetime defaults to the one the token
was last issued with (clamped to today's policy) or to expires_in_days when
given. An expired token can be renewed this way; a revoked one cannot. It is
session only like the rest of token management, and writes a pat_regenerated
audit event. regenerated_at records the rotation.
2026-09-21 10:32:17 +01:00
arc53-machine 3e38dc19ec docs: key CI source uploads by commit so a revert is ingested again 2026-09-20 19:47:26 +01:00
arc53-machine 94b5924e36 fix(pat): close restricted-token paths through workflows, chat, schedules and conversations
A resource restriction was checked on ids in the request, not on what the
addressed row pulls in or belongs to. Closed:

- Workflow writes for tokens restricted on sources, tools or prompts (a graph
  names those inside its nodes), and attaching a workflow to an agent unless
  the token is restricted on workflows too.
- Chat for tools-restricted tokens (chat executes tools; rejected at token
  creation as well), and agent-less chat for tokens restricted on prompts or
  workflows.
- conversation_id on chat: it must belong to the agent being run, or to no
  agent for agent-less chat. Otherwise the server continued, appended to, or
  resumed pending tool calls of another agent's conversation.
- Schedules for tokens restricted on anything but agents; schedule-id routes
  for every restricted token.
- Conversations and analytics for every restricted token, not only
  agent-restricted ones.

Also: create, first publish and adopt return the agent API key masked to a
token without agents:keys; token ids must be canonical UUIDs (urn:uuid: gave
a 500); an expired token is reported as expired; token creation takes a
per-user advisory lock so the cap cannot be raced; admin revoke-sessions
writes a pat_revoked event per token. The UI drops a row whose revoke returns
404 and does not offer a tools restriction next to chat:run.
2026-09-20 19:46:43 +01:00
arc53-machine b1ff8f516e fix(frontend): access tokens dates and names render unescaped, align the scopes block
i18next HTML-escaped interpolated values, so expiry dates showed as
26&#x2F;09&#x2F;2026 and token names containing & or quotes were mangled in
the created and revoke dialogs; React already escapes on render, so those
strings opt out like utils/streamingStatusUtils does. The scopes fieldset
drops the browser's default padding so it lines up with the other fields, and
the native checkboxes follow the dark colour scheme.
2026-09-20 13:32:46 +01:00
arc53-machine 7ddb5f6400 fix(pat): align replay scopes, keep 404/405, retire expired names, document the PAT_ENABLED switch
The ASGI message events route now accepts the same scopes as its Flask
sibling (conversations:read or chat:run) through a shared constant. A token
request that fails routing gets Flask's 404/405 instead of a 403. Creating a
token retires an expired token that still held the name. allowed_ids uses
is_pat instead of a bare literal. PAT_ENABLED is documented as the master
switch it is: turning it off stops every existing token from authenticating.
The docs explain that sources are matched by name (oldest wins) and point CI
flows at sources upload --replace.
2026-09-20 12:12:42 +01:00
Alex 9ad09039e4 Merge pull request #2804 from arc53/fix/graphrag-extraction-and-retrieval
fix(graphrag): make graph retrieval and extraction work in a default install
2026-09-20 11:20:54 +01:00
Alex 63d93fc4b9 test(graphrag): pin that identical pages collapse into one
Review asked whether two document rows with the same text and metadata should
stay two pages. They should not: the caller gets four pages to hand a model,
and a crawl that ingested the same text twice would spend two of them on it.
The rows differ only by an id the model never sees. Pinned either way now.
2026-09-20 10:54:55 +01:00
Alex 692edcdd4e test(agents): give the graph tool tests the vector store they need
The tool now asks graphrag_available(), which wants pgvector as well as the
flag. One case set only the flag and passed locally off a dev .env, then
failed on CI's faiss default. An autouse fixture sets it for the module, so a
case that forgets fails for its own reason; the two about a different vector
store override it themselves.
2026-09-20 10:54:55 +01:00
Alex ef5b718f37 fix(graphrag): drop entities whose name normalizes to nothing
``canonical_name`` answers "" for a punctuation-only name, which callers are
meant to read as "no entity" -- ``_resolve_endpoint`` already does. Entity
extraction did not, and nodes merge on that key, so every such entity in a
source collapsed onto one shared node that belonged to none of them.
2026-09-20 10:45:05 +01:00
Alex 2b6d4d509e fix(graphrag): return a chunk once from entity_pages
The subject flag was in the GROUP BY, so a chunk two matching entities link --
one the page is about, one merely mentioned in it -- came back as two
identical pages and spent the caller's page budget twice on the same text.
It is aggregated with bool_or now, which is what the ordering wanted anyway.

Covered by a live test against a pgvector-shaped documents table: the graph
tables alone cannot answer this query, so nothing exercised it before.
2026-09-20 10:45:04 +01:00
Alex ccd8eb612f fix(agents): hand the graph tool's connection back, and gate it like the rest
Three faults in the graph tool, all on the agent's path:

The store was cached on the tool, and the executor caches the tool for the
whole agent run -- so one pgvector pooled connection stayed checked out across
every LLM round trip of that run, minutes at a time, and enough concurrent
runs exhaust the pool. GraphRAGRetriever releases its store before falling
back for this reason. The tool now releases it at the end of each action.

It gated on GRAPHRAG_ENABLED where everything else asks graphrag_available(),
which also requires the pgvector store. Under any other vector store the graph
tables are not the ones the sources were ingested into, but the tool was still
offered and still queried Postgres.

Pages were labelled by hand rather than through labels_from_metadata, which
exists so citation labels match across retrievers. A page read by the tool and
the same chunk retrieved by internal_search are one document, and citations
key on (source, title) -- so the research agent gave that document two
citation numbers. The recorded doc also keeps the full chunk text now, so it
dedupes against the retriever's copy; only what the model reads is truncated.
2026-09-20 10:45:04 +01:00
Alex bc0ef9f3b0 fix(retriever): search a graph source classically when its graph answers nothing
Only a raise routed a source to the ClassicRAG fallback, and every graph read
logs its own failure and returns empty. So a query that broke, a half-built
graph and a walk that genuinely found nothing were indistinguishable, and each
made the source contribute nothing at all to the answer -- no fallback, no
vector blend, which is skipped by the same early return.

A graph source that produces no documents now joins the classic batch, exactly
as a source with no graph already does.

The passage stage also rescanned every node's chunk list once per candidate.
It inverts the links once instead: 7.3 ms to 0.16 ms on a 400-node subgraph at
the candidate cap, with identical output.
2026-09-20 10:45:03 +01:00
Alex 8c5a5190d9 fix(retriever): carry a graph source's own options to the retriever
The three per-source graph options are read from the retrieval config the
Dispatcher hands over, and it only hands one over for a source it considers
overridden -- which it decided from chunks, score_threshold, rephrase_query
and prescreen alone. A source that changed only its graph options was not
"overridden", so nothing was carried and every graph source ran the defaults:
the UI toggles did nothing at all.

They count as an override now, for graphrag sources only. They mean nothing to
any other retriever, and an override also hands the source its own chunk
budget, which a classic source must not pick up from a graph setting.
2026-09-20 10:45:03 +01:00
arc53-machine 934ae1afb3 fix(pat): close message replay to restricted tokens, filter builtin tools, reject non-object bodies
Message tail and the ASGI reconnect stream cannot tie a message to an
allowlist, so any token with a resource filter is refused there. The tools
listing now applies the allowlist to default and builtin rows as well. Token
creation answers 400 instead of 500 for a JSON body that is not an object.
2026-09-19 23:54:35 +01:00
arc53-machine 17d66c061d docs: personal access tokens guide 2026-09-19 23:39:58 +01:00
arc53-machine 225d9d3065 feat(frontend): access tokens settings tab
Settings > Access Tokens lists a user's personal access tokens and lets them
create and revoke tokens: scopes grouped by family, optional restriction to
specific resources, and an expiry bounded by the server's policy. The secret
is shown once after creation and never stored client side. Removes the
unused legacy api-key endpoints, wrappers, type and locale block.
2026-09-19 23:39:58 +01:00
arc53-machine 82ec6cba0e feat(pat): enforce scopes and resource restrictions, deny by default
A central rule table maps each route and method to the scope a token needs;
a route that is not listed cannot be called with a token, and a test fails
when a registered route is left unclassified. Token management, admin, team
management, sign-in, device pairing and OAuth handshakes are never token
reachable, and a token never carries the admin role.

A token restricted to specific agents, sources, prompts, tools or workflows
is held to its allowlist: ids are checked wherever a route carries them,
listings are filtered, creation is refused, and routes whose rows cannot be
tied to the allowlist are closed. Agent import checks the resolved target.
2026-09-19 23:39:58 +01:00
arc53-machine 3a74aa23f0 feat(pat): token management API and admin revocation
Users list, create and revoke their own tokens under /api/user/tokens; the
plaintext is returned once at creation. Admins can list a user's tokens and
revoke any token, and the admin revoke-sessions action now revokes the user's
tokens too. Creation and revocation are written to auth_events.
2026-09-19 23:39:58 +01:00
arc53-machine 174130607a feat(pat): authenticate personal access tokens
handle_auth resolves a dgpt_pat_ bearer against the database instead of
decoding it as a JWT, for both the Flask and the ASGI routes. Scopes and the
resource filter always come from the token row, and the claims that mark a
PAT are stripped from decoded JWTs so a session token cannot pose as one.
ASGI routes reject tokens unless they name the scope that admits them, and
/api/user/me reports what the calling token may do.
2026-09-19 23:39:58 +01:00
arc53-machine 22ecc0aee3 feat(pat): add personal access token storage and settings
A personal_access_tokens table (migration 0032) holds scoped user-level API
credentials. Only the SHA-256 of the secret is stored, like device session
tokens. Lookups exclude revoked and expired tokens and the tokens of
deactivated users. PAT_* settings cover the feature switch, default and
maximum lifetime, the operator opt-in for non-expiring tokens and the
per-user cap.
2026-09-19 23:39:58 +01:00
Alex ecf02d0d60 fix(graphrag): take a source's write lock per chunk, and keep zero weights
Two builds of one source can overlap: the extraction lease is keyed by the
source's updated_at, and enabling a graph updates the source before it
dispatches, so a rebuild started while the last build runs gets a new key and
a lease of its own. Both builds could then pass a chunk's "done" check before
either committed and apply it twice -- doc_freq bumped twice, reproduced with
two live writers. A reset could also land in the middle of a chunk.

apply_chunk and delete_by_source now take a transaction-scoped advisory lock
keyed by the source before touching a row, as the schema bootstrap already
does for DDL. A single build's writes were already serial, so it loses
nothing; overlapping builds take turns chunk by chunk, and the second sees the
first's "done" row and returns (0, 0).

apply_chunk also still defaulted with `rel.get("weight") or 1.0`, turning an
explicit zero into a full-strength edge -- the conversion 3f774d81 removed from
add_edge and the ranker but missed here. Only a missing weight defaults now.
2026-09-19 20:27:27 +01:00
Alex b3d7e63ae3 fix(graphrag): compose graph SQL through psycopg instead of f-strings
Four graph-store queries formatted table and column names into the SQL
string: get_chunk_texts and delete_by_source (Bandit B608, alerts #582/#583
on main) and entity_pages/chunk_similarities from this branch (#661/#662,
dismissed). The names were validated by _safe_identifier, so none was
injectable, but each query was still a string built at runtime.

They are now fixed statements composed with psycopg.sql: identifiers go in as
sql.Identifier, values stay bound. Identifiers are lower-cased before quoting,
because PGVectorStore writes the same names unquoted and Postgres folds those
to lower case -- a quoted mixed-case name would address a different table.
Bandit reports nothing for docsgpt/graphrag now; the queries return the same
rows against a real graph as before.
2026-09-19 20:10:54 +01:00
Alex 558f803fed test(worker): send the real startup signals, and import celery_init one way
The lifecycle tests imported docsgpt.celery_init as a module next to the
file's from-imports, which code scanning flags. Patch the flag by path and send
the signals from celery.signals -- the objects a worker actually fires.
2026-09-19 14:53:18 +01:00
Alex 5e06470f9e test(graphrag): cover the graph reads, graph tool and vector blend without pgvector
CI has no pgvector, so every live graph test skips there and the queries this
branch added ran in no CI job at all. These pin what holds without a
database: the four new read queries bind every value (the entity name comes
from an LLM tool call) and map their rows; empty input runs no query; a failed
query returns nothing and releases its connection. The graph tool's plumbing,
the sources_have_graph gate and the hybrid path's vector ranking get the same.

Also drops a redundant chained comparison flagged by code scanning.
2026-09-19 14:33:14 +01:00
Alex b32d27c912 fix(agents): cite the pages the graph tool read
The graph tool records every page read_entity_pages returns in
retrieved_docs, but agents only ever collected internal_search's. A turn
answered from graph pages therefore emitted no sources: on the multi-hop e2e
run, a question answered from two read_entity_pages calls came back with none.

_search_tool_docs gathers both tools' documents the way the executor caches
them, and both the classic/agentic collector and the research agent's
per-step citations use it. Checked against the real executor and a real
graph: a graph-only turn now cites the pages it read.
2026-09-19 14:33:14 +01:00
Alex 877609dfb6 fix(worker): record worker state at startup, for every pool
in_worker() read task_join_will_block, which eventlet and gevent leave unset,
and the task's current_worker_task, which they scope to one greenlet -- so a
greenlet a task spawned in those pools still took the web-process branch and
dispatched to its own worker, where it could queue behind its parent and time
out.

The worker's own startup now records it: worker_init fires in every worker's
main process before the pool starts (where solo, threads, eventlet and gevent
run tasks, and what prefork children fork from), and worker_process_init in
each prefork child. worker_ready would be too late -- prefork children are
forked before it fires. The two existing checks stay for anything that runs
tasks without that startup.
2026-09-19 14:33:14 +01:00
Alex a20402a6dc fix(graphrag): give each extraction thread its own LLM
Provider-reported usage is kept on the LLM instance (_last_usage) and claimed
by whichever call finishes next. With GRAPHRAG_EXTRACTION_WORKERS > 1 (the
default is 8) every extraction thread shared one instance, so a call could
claim another call's provider counts while its own fell back to the estimate:
token_usage rows, and the cost they bill, could be attributed to the wrong
call and summed wrong.

Each pool thread now builds its own extraction LLM on first use. The calling
thread's instance is still built up front, so a misconfigured model fails the
run before any chunk is touched.
2026-09-19 14:33:14 +01:00
Alex 022cf69b49 fix(graphrag): fold an entity's singular and plural onto one key
canonical_name dropped "es" from every -ches/-ses/-zes plural, so "caches"
became "cach" while "cache" stayed "cache" -- the singular and plural landed on
two nodes, which is the split the function exists to prevent. The same rule
split the words documentation uses most: databases/database, responses/
response, releases/release, sizes/size.

An "-es" plural cannot say whether it is cache + "s" or batch + "es", so
instead of guessing, both sides now meet at the stem: the singular endings
-che/-she/-se/-ze/-xe drop their "e" the way the plurals drop "es", and a
singular's -ie folds to -y as -ies already did (cookie/cookies). The key is a
merge key that is never shown, so it only has to agree, not be a word. alias,
canvas, atlas and bias join the words that only look plural.

Found by the naming tests CI was missing: the module had no direct tests.
2026-09-19 14:33:14 +01:00
Alex 5285bb4115 fix(graphrag): give a failed chunk one more attempt before giving up
A chunk whose extraction failed was marked failed and the build moved on. The
checkpoint treats failed chunks as pending, but nothing ever reran the build,
so one transient error -- a provider hiccup, a single response that did not
parse -- left a permanent hole in the graph until someone rebuilt the whole
source.

Failed chunks now get one more attempt after the rest of the build, so a burst
of rate limiting has time to pass. Extraction errors, unparseable responses and
failed writes are all retried; a chunk is marked failed only when its retry
fails too, so it costs at most two calls. The chunks given up on are logged by
id, and progress still ends at the total.
2026-09-19 14:07:42 +01:00
Alex 15bdda8554 fix(worker): know you are in a worker from any thread, not only the task's
Celery records the executing task on the thread that runs it, so a thread that
task starts sees none. The embeddings client and read_document both decided
"am I in a worker?" from that alone, and from any other thread took the
web-process branch: dispatch to the worker they were running in and block on
the result. Celery refuses that get() ("Never call result.get() within a
task!"), so the embed failed and latched the 30s dispatch cooldown for every
caller after it; with joins allowed, read_document would instead wait on a
parsing queue only its own busy process serves.

Threads inside tasks are not hypothetical: per-source retrieval fans out to a
pool, so a scheduled or webhook agent searching several sources embedded from
pool threads. Graph extraction did too, which failed every chunk of a build.

in_worker() in celery_init answers for the whole process. Celery's
task_join_will_block is process-wide and set for every blocking pool (prefork,
solo, threads) -- exactly the condition under which dispatch-and-wait goes
wrong; eventlet/gevent leave it unset, so the task's own thread still counts
through current_worker_task. Verified with real workers on each blocking pool:
from a thread a task started, the old check dispatched and hit the error, the
new one embedded locally.
2026-09-19 14:07:42 +01:00
Alex 5e67f8c927 feat(settings): graph retrieval options in a graph source's retrieval settings
Exposes the three per-source graph options in the source's retrieval settings,
shown only when the retriever is graphrag: where the walk starts (entities or
relationships), whether passages join the walk, and whether vector hits are
blended in. Defaults match the backend's measured-best configuration and are
filled in for sources saved before the options existed. A note points at the
"search tool" exposure, which is what offers the graph to an agent.
2026-09-19 14:07:41 +01:00
Alex 6b9b193337 feat(agents): let agents walk a source's graph when they can search it
A one-shot graph ranking diffuses over the whole neighbourhood; a question
whose answer sits two hops away is better served by following the edges. The
graph_search tool gives an agent search_entities, get_relationships and
read_entity_pages over its graph sources. On a multi-hop corpus where the
bridging entity is never named, answers went from 0/10 with classic vector
retrieval to 10/10 with the tool, end to end through /stream.

The tool has no setting of its own. It is offered exactly where the agent can
already search: agentic and research agents always, a classic agent only for
sources exposed as a search tool. A graph source left at prefetch is used for
ranking only.

Tests now pin GRAPHRAG_ENABLED to its shipped default, as CI has: with a dev
.env enabling it, every agent test's graph check read the developer's real
database and left a pool to it behind.
2026-09-19 14:07:41 +01:00
Alex a83e1dc0af feat(graphrag): seed the walk from what entities are, and rank with passages and vector hits
Graph retrieval tied plain vector search at best and never beat it. Measured
across five corpora, the bottleneck was seeding, not the graph: the walk
started from nodes whose embeddings were computed from bare entity names, and
a whole question shares almost nothing with a name like "Quill".

Extraction now embeds each node from "name (type): description" and each
relationship as the fact it asserts ("Alder streams_to Quill: ..."), stored on
a new nullable graph_edges.fact_embedding column that ensure_vector_schema adds
in place. Entity names are canonicalised (case, punctuation, word breaks and a
cautious plural) so "VECTOR_STORE" and "vector stores" land on one node. Extraction calls run
concurrently (GRAPHRAG_EXTRACTION_WORKERS, default 8) while embedding and graph
writes stay serial on the task thread, so ordering and idempotency are
unchanged; that measured 8.4x faster with identical output.

Retrieval gains per-source options, stored under retrieval.graph and read live
at query time:

- seed_strategy: start from matching entities (default) or matching
  relationships, which can reach an entity the question never names;
- passage_nodes (on): walk the source's passages alongside entities, with
  PageRank damping 0.5 instead of 0.85;
- blend_vector (on): fuse the graph ranking with the source's vector ranking
  by reciprocal rank.

The defaults are the measured-best configuration. Through GraphRAGRetriever,
the new seeding moved recall@4 from 0.41 to 0.68 on a multi-hop corpus and
from 0.50 to 1.00 on the docs corpus, and regressed none of the corpora
measured. Existing graphs keep name-only embeddings until rebuilt.
2026-09-19 14:07:41 +01:00
Alex 0952a05aab Merge pull request #2805 from arc53/widget-license-upd 2026-09-17 22:17:59 +01:00
Pavel 762c7a19cd correct license for widget 2026-09-17 23:50:14 +04:00
Alex b7e7872bf7 fix(tasks): a deferred duplicate records no result, rather than success
Returning a deferred marker traded one wrong signal for a worse one. A
redelivery reuses the original task id — Context.as_execution_options carries
task_id into the retry — so the duplicate's return marked the very id the
client polls as SUCCESS. /api/task_status reports celery's state verbatim and
the UI maps SUCCESS to "done", so the GraphRAG enable modal would announce a
finished build, rendered from a payload with no counts, while the run holding
the lease was still extracting.

Raise Ignore instead: celery records no state for the duplicate, so the task
id keeps whatever the holder sets and the poller keeps waiting. The autoretry
wrapper re-raises Ignore ahead of autoretry_for, so the wider
autoretry_for=(Exception,) on these tasks cannot turn it back into a retry.
2026-09-17 16:51:06 +01:00
Alex 3f774d813c fix(graphrag): review fixes — replay-safe writes, strict count, zero weights
Three findings from review, all on code this branch introduced.

apply_chunk was not replay-safe. commit() can report connection loss *after*
Postgres committed, and the reconnect retry then replays the write: _upsert_node
bumps doc_freq a second time and _add_edge inserts another row, since
graph_edges has no uniqueness constraint for a logical edge. The chunk's
graph_ingest_progress row is now written in the same transaction as the rows it
describes, and a replay that finds it already "done" returns (0, 0) without
touching the graph. Extraction drops its separate mark_chunk("done"): the
checkpoint and the graph can no longer disagree.

count_nodes swallows every query failure and answers 0, so extraction's
"fall back to the write count" handler could never run — a failed count after a
successful build reported an empty graph. count_nodes grows a strict mode that
re-raises; retrieval keeps the swallow, which is what routes a source to
ClassicRAG.

A zero edge weight was read as a full-strength link: `or 1.0` rewrote an
explicit 0 before the <= 0 filter. Only missing and null weights default now.
The same coercion sat in _ppr_scores, where it would have kept the ranker's
rule unreachable from the product path, so it is fixed there too.
2026-09-17 16:31:19 +01:00
Alex 9e9f130ed0 refactor(graphrag): spell the write retry out, and log a capped PPR run
Review feedback on _write_with_reconnect: an explicit return inside the loop
plus an implicit fall-through past it reads as a path that returns None. The
retry is exactly two attempts, so say so — first attempt, reconnect on
connection loss, second and final attempt — and every path now returns or
raises.

Also narrows the transitions hint to the (node, weight) tuples it holds, and
logs at debug when the power iteration hits its iteration cap instead of
returning the last iterate with no trace.
2026-09-17 16:03:39 +01:00
Alex 6ea737e57b fix(tasks): a deferred duplicate stands down instead of failing the task
A task that runs longer than the broker's visibility timeout is redelivered
while its first run is still going. The idempotency lease correctly stops the
duplicate from doing the work, but the duplicate then re-queued itself once
per LEASE_TTL until celery ran out of retries and raised
MaxRetriesExceededError — so a perfectly healthy long task (a large graph
extraction is the one that found this) reported a task failure, with a
traceback, while the real run was still making progress next to it.

Catch the exhaustion and return a "deferred" result instead. A normal
deferral still re-queues: only the give-up path changes, and the lease
holder's dedup row is left untouched so its own completion still records.
2026-09-17 15:49:56 +01:00
Alex e3d819d9fd fix(graphrag): stop losing chunks silently during a graph build
Three ways a chunk disappeared from a graph with no way to tell:

A build checks out one connection and then spends minutes per chunk waiting
on the model, so the connection idles long enough for the server or a pooler
to drop it. The pool only validates a connection when it hands one out, and
this one was handed out at the start of the build, so the next write raised
"the connection is lost", the chunk was marked failed, and the build carried
on a chunk short. apply_chunk and mark_chunk now reconnect and retry once;
every statement they run is an idempotent upsert, so a replay cannot
double-write. Only connection loss retries — a bad statement still surfaces.

An unparseable model response marked the chunk failed and logged nothing at
all, so failed_chunks was the only evidence and it named no chunk. Both
failure modes now log the chunk id.

The summary's node count summed per-chunk upserts, so an entity appearing in
ten chunks counted ten times: it reported writes, not graph size. It now
reports the distinct node count, falling back to the write count only if the
count query fails.
2026-09-17 15:44:23 +01:00
Alex 92e19ac177 fix(graphrag): dispatch extraction to the provider that serves the model
_build_extraction_llm passed settings.LLM_PROVIDER with the resolved
extraction model id. Those two disagree in any deployment that leaves the
provider at its default: the model id comes from GRAPHRAG_EXTRACTION_MODEL
or LLM_NAME, while the provider stays "docsgpt" — the hosted public
endpoint, which does not serve it. The request is rejected, the shared
fallback answers instead, and the graph gets built by a different model
than the one configured, with nothing in the summary saying so.

Resolve the provider from the model registry (owner-scoped, so per-user
BYOM ids resolve too), fall back to settings.LLM_PROVIDER only when the
model is unknown, and take the API key for the provider actually dispatched
to rather than the generic settings.API_KEY. The effective provider is
logged, since a silent swap was the whole failure mode.
2026-09-17 15:40:20 +01:00
Alex 94a33c4781 fix(graphrag): rank without scipy so graph retrieval stops falling back
networkx.pagerank delegates to a scipy implementation, and scipy is not a
DocsGPT dependency — it only arrives transitively through the optional
docling extra. In a default install every graph retrieval raised
ModuleNotFoundError inside _ppr_scores, hit the per-source except, and
degraded to ClassicRAG: the graph was built and paid for, then never used,
with one ERROR line per source per query as the only signal.

Rank with a local power iteration over the same row-normalized transition
matrix: undirected edges normalized per endpoint, dangling nodes
redistributed along the restart vector, and the restart vector normalized
across the nodes the subgraph actually holds so seed mass cannot leak.

Parity with networkx is asserted while scipy happens to be installed in the
test env, and the retrieval path is exercised with the import blocked.
2026-09-17 15:38:54 +01:00
Alex 24bcbd938e Merge pull request #2795 from arc53/ui-scan
UI refinements
2026-09-17 15:16:04 +01:00