A complete walkthrough of how the three tiers (Vue → Laravel → FastAPI → DeepSeek) collaborate to deliver asynchronous, cache-aware, legal document analysis with inline highlight overlays.
OpenAct's document-analysis engine takes a legal document (PDF, DOCX, or plain text) that a user has previously uploaded into a chat, and produces a structured, professional review of it. The review includes:
- A risk score (0–100) and label (Low / Medium / High / Critical).
- Highlights — verbatim excerpts from the document, each tagged with a category
(
risk_high,risk_medium,risk_low,clause_key,obligation,party,date), an explanation, a section hint, and an optional page number. - Aggregate metrics — clause count, obligation count, parties list, key dates, executive summary.
- Optional two-document comparison, showing added obligations, removed protections, and material changes across versions.
The frontend does not just display these results — it paints them directly on top of the rendered
document inside a pdf.js canvas or a mammoth-rendered DOCX, so the lawyer can click a finding
in the sidebar and land exactly on the relevant clause.
The entire analysis pipeline is asynchronous: the HTTP call that kicks off an analysis returns
within milliseconds with a pending record; the heavy AI work happens in a background worker; the
SPA polls for completion.
| Tier | Technology | Single responsibility |
|---|---|---|
| Frontend | Vue 3, Pinia, Vite, pdf.js, mammoth.js | Render documents + overlay highlights + orchestrate UX (tabs, compare, tooltip) |
| API Gate | Laravel 11, Sanctum, PostgreSQL, Redis | Auth, authorization, persistence, job dispatch, idempotency |
| AI Kernel | FastAPI, Pydantic v2, loguru, OpenAI SDK | Prompt construction, LLM call, JSON parsing, response validation |
| LLM | DeepSeek v3 (chat.completions) |
Raw intelligence — produces JSON per the system prompt's schema |
Every tier has a narrow contract with the next: the browser talks JSON over Sanctum cookies to Laravel; Laravel talks JSON + a shared-secret header to FastAPI; FastAPI talks the OpenAI chat protocol to DeepSeek. Each boundary is also a trust boundary.
This is the golden path — what happens when the user clicks "Analyze" on a document for the first time.
DocumentAnalysisPanel.vue is mounted with a document prop. On mount it calls
useDocumentAnalysis(), which wraps a Pinia store and exposes triggerAnalysis(). If nothing is
cached for the current (documentId, analysisType, compareDocumentId) key, the composable POSTs:
POST /api/chats/{chat}/documents/{document}/analyze
Cookie: laravel_session, XSRF-TOKEN
Body: { "analysis_type": "full" }
The axios instance (services/axios.js) automatically attaches withCredentials: true and
mirrors the XSRF cookie into the X-XSRF-TOKEN header. A 401 anywhere forces a redirect to
/login.
DocumentAnalysisController::analyze runs inside the auth:sanctum + throttle:60,1
middleware group. It performs four things in order:
- Authorize —
authorizeAccess()aborts 403 if the chat is not owned by the user or the document does not belong to the chat. - Validate —
analysis_typemust be in the closed set{full, risk, clauses, compare},compare_document_idmust be a valid UUID that exists onchat_documents, andforceis coerced into a boolean. - Readiness gate —
$document->isReady()guards against analyzing a file whose text extraction has not finished yet (returns422 DOCUMENT_NOT_READY). - Idempotency check — unless
force=true, the controller looks up the latestdoneanalysis for that exact(document, analysis_type, compare_document_id)triplet and returns it verbatim viaDocumentAnalysisResource. This turns repeated clicks on the same tab into free cache hits.
Only when the cache misses does it:
- Delete stale
pending/failedrows (so the index on(chat_document_id, status)stays tight and the UI never sees an old error). INSERTa freshDocumentAnalysisrow in statepending.- Dispatch
ProcessDocumentAnalysis::onQueue('streaming')to Redis. - Return
202 Acceptedwith the pending resource — shape is identical to the eventualdoneshape so the frontend can render skeletons consistently.
php artisan queue:work --queue=streaming --tries=2 --timeout=120 (the
laravel-worker container from docker-compose.yml) picks up the job. ProcessDocumentAnalysis::handle():
- Re-loads the
DocumentAnalysisrow by id, or bails quietly if it was deleted. - Re-checks
ChatDocument::isReady()— between dispatch and run the file could have been invalidated; if so, the job marks the rowfailedand exits. - Updates status →
processing. - Builds the kernel payload from
ChatDocument::extracted_text(pluscompare_text/compare_filenamewhen present). - Reads
config('services.kernel.url')andconfig('services.kernel.secret'), then:
Http::timeout(110)
->withHeaders(['X-Kernel-Secret' => $secret])
->post("{$kernelUrl}/analyze-document", $payload);The 110s client timeout is deliberately shorter than Laravel's 120s job timeout — the HTTP call will always fail before the job itself dies, so the exception path runs predictably.
On failure, behaviour depends on the attempt counter. The first failure throws back to the
queue so Laravel schedules a retry (up to $tries = 2). The final failure calls markFailed()
which persists status=failed + error_message. Successes call writeResult() which stamps
highlights, metrics, risk_score, risk_label, compare_highlights, compare_summary
onto the row and clears error_message.
FastAPI boots from kernel/main.py. Two middlewares wrap every request:
attach_request_id— readsX-Request-IDor mints a UUID, then binds it to the loguru context so every log line inside the request carries the correlation id. The header is echoed back on the response, so Laravel logs and Kernel logs can be joined on that id.verify_secret— rejects any non-/healthrequest whoseX-Kernel-Secretdoes not match$PYTHON_KERNEL_SECRET. This is a flat-string equality check — fine for an internal Docker network, and trivially swappable for mTLS or AWS IAM later.
The POST /analyze-document handler:
- Parses the body into an
AnalyzeDocumentRequestPydantic model. Thetext_not_emptyvalidator rejects documents under 50 characters with HTTP 422. - Delegates to the singleton
document_analysis_service.analyze(req). - Catches
ValueError(validation / parsing) → 422 with the reason. - Catches everything else → 500
"Analysis failed. Please try again."(the full traceback goes tologger.exception, never to the client).
This is where prompt engineering lives. Four system prompts are pre-built at module load:
_SYSTEM_FULL— identity as "OpenAct Engine", senior legal AI for Uzbekistan-based practice. Demands verbatim excerpts, 8–40 highlights,risk_scorescaling, and a strict JSON schema with all seven highlight categories._SYSTEM_RISK— narrows the remit to risk only. Instructs the model to look for missing protections, liability exposure, unilateral termination, vague language, penalty and jurisdiction issues. Metrics reduced accordingly._SYSTEM_CLAUSES— key contractual clauses only (definitions, payment, IP, confidentiality, warranties, termination, dispute resolution, governing law)._SYSTEM_COMPARE— two-document diff. Produceshighlights_a,highlights_b, and acompare_summarynarrating material differences.
_analyze_single(req):
- Selects the prompt via
_SYSTEM_MAP[analysis_type](defaults to_SYSTEM_FULL). - Truncates the document at
_MAX_CHARS = 60_000to cap token spend and stay inside DeepSeek's context. Truncation is explicit — it appends"[... document truncated for analysis ...]"so the model knows the cut is not an artefact. - Composes a two-message conversation: the chosen system prompt + a user message containing filename and the framed document body.
- Calls
deepseek_client.generate()withmodel="deepseek-v3",temperature=0.1(near-greedy — we want deterministic legal output), andmax_tokens=6000. - Parses the response via
_safe_parse→_parse_json→ strips```jsonfences and falls back to a brace-scan regex if the LLM slips in extra prose. - Hydrates highlights with
_build_highlights(clamps text at 500 chars, assigns fallback idsh1..hN, skips malformed entries with a warning instead of failing the whole response). - Hydrates metrics with
_build_metrics— clampsrisk_scoreinto[0, 100], assigns the human label via_RISK_TIERS, and recomputesflagged_countasmax(claimed, actual)so a model that lies-low on its own flag count still gets corrected. - Returns a fully validated
AnalyzeDocumentResponse.
_analyze_compare(req) mirrors the above but halves _MAX_CHARS for each document, composes
an "A vs B" user message, uses _SYSTEM_COMPARE, and splits the output into
highlights (= A), compare_highlights (= B), and compare_summary.
The JSON response flows back to the worker, which unpacks it into the DocumentAnalysis Eloquent
row. Because the model casts highlights, compare_highlights, and metrics as array,
PostgreSQL's JSON columns are written transparently via json_encode. The row is now in state
done and immediately visible to any subsequent GET by the frontend.
While the job is running, useDocumentAnalysis._startPolling() has been hitting
GET /api/chats/.../analysis?analysis_type=full every 2 500 ms. The show controller method
returns the latest row for that exact view (404 if none). As soon as the poller observes
status === 'done', it stops, the composable's analysisLoading flips to false, and Vue's
reactivity chains through:
DocumentAnalysisPanelre-computesactiveHighlights.DocumentAnalysisSidebarrenders the risk gauge, metrics row, parties/dates chips, and the grouped findings list.DocumentViewerfires itswatch(() => props.highlights, ...)and calls_applyHighlights().
applyHighlights() in utils/documentHighlighter.js is the interesting part. For each
highlight it walks the DOM of the viewer container with a TreeWalker filtered to text nodes
(skipping <script>, <style>, and existing <mark> nodes), looks for the verbatim substring,
and surgically splits the text node into three — before, a new <mark data-hl-id="..." data-hl-category="...">, and after. The <mark> gets its own inline background + accent
border driven by HIGHLIGHT_COLORS[category], plus click and hover listeners. Click events
bubble up through DocumentViewer → DocumentAnalysisPanel → onHighlightClick, which opens
the DocumentHighlightTooltip (a Teleport-ed popover anchored to the clicked <mark>'s
bounding rect).
The sidebar reverses the flow: clicking a finding calls onFindingClick, which sets the active
highlight id and asks the viewer to scrollToHighlight(id). That helper finds the <mark>,
scrollIntoView({behavior: 'smooth', block: 'center'}), and pulses its filter: brightness
via a small CSS animation so the eye locks onto it.
- Switching tabs (
full→risk) —useDocumentAnalysis.setAnalysisType('risk')first looks in the Pinia store for a cachedfullresult; if present, it derives the risk view in-memory by filtering highlights to the risk categories and tagging the derived result_derived: true. No extra HTTP call. Only when the cache is cold does ittriggerAnalysis()against the Laravel endpoint, which itself will either cache-hit or dispatch a fresh job. - Compare mode — flipping the compare toggle sets
analysis_type = 'compare'and triggers a new analysis that carriescompare_document_id. Laravel and the kernel both treat this as a distinct row in the DB and a distinct cache key on the client. - Re-analyze — the reload button sends
force=true; the controller skips its idempotency check and always dispatches a new job.
This section is a running commentary on the design choices, not just the code.
A monolith would be simpler — Laravel could call DeepSeek directly via Http::post. It wasn't
done that way for three reasons:
- Concurrency model fit. The document-analysis workload is 90% waiting on a third-party
LLM. Python's
asyncio+ FastAPI handles that trivially with a single event loop; PHP-FPM would have to tie up a worker process per in-flight request. The dedicated Laravel queue worker (laravel-worker) hands off to FastAPI and sleeps, so neither tier is blocked. - Prompt evolution velocity. Prompt engineering is its own discipline and changes often.
Keeping it in a Python module alongside the DeepSeek client lets AI engineers iterate on
_SYSTEM_FULLwithout touching Laravel, running PHP tests, or rolling the whole PHP image. - Vendor substitution. The kernel is the only place that knows DeepSeek exists. Switching
to Claude, GPT-5, or a local model means editing
deepseek_client.py— nothing else moves.
LLM calls commonly take 10–60 seconds. Three problems this creates for a synchronous handler:
- The browser sits on an open TCP connection, which load balancers and CDNs dislike.
- A retry scenario (transient
502from DeepSeek) would re-run the entire analysis from scratch, wasting tokens. - A burst of 20 simultaneous uploads during a busy hour would lock 20 PHP-FPM workers.
The queue decouples "user asked for this" from "work is happening", lets the worker retry
cleanly ($tries = 2), and keeps PHP-FPM workers free for the fast endpoints that pollfor
results.
A lawyer clicking "Full Review" twice in five seconds should not run two inference jobs. The
controller's idempotency check collapses duplicates into cache hits, which is cheap, correct,
and feels instant in the UI. The force=true escape hatch is there for when the user has
updated their prompt (version of the engine), is debugging a bad result, or wants a fresh pass
after editing the document.
The uniqueness key is (chat_document_id, analysis_type, compare_document_id) — indexed as
da_doc_type_compare. Anything finer-grained would miss hits; anything coarser would leak
results across tabs.
At the traffic level the product needs today, polling is strictly simpler: no broadcaster, no
sticky sessions, no reverse-proxy WebSocket upgrade dance. The composable caps the poll at
MAX_POLLS = 48 × 2 500 ms ≈ 2 minutes, which exceeds the worker's $timeout = 120s. If the
worker is going to succeed, the poller will see it. If it's going to fail, the poller will see
that too, and analysisError flips true in the sidebar.
The contract leaves the door open for a swap: the backend's REST pattern (POST to enqueue,
GET to observe) is equivalent to the control surface you'd want from a Server-Sent Events
stream.
Two independent reasons, each sufficient on its own:
- It generates an OpenAPI schema that Laravel (or any other caller) can validate against.
- It refuses to let a hallucinated or malformed LLM response escape the kernel —
AnalyzeDocumentResponseis the last gate before the payload is handed back to the worker, and any structural violation throws a typed error we can report cleanly.
Legal review is not a creative task. We want the same document to produce (near) the same
highlights on every run. 0.1 is low enough to make the output stable across retries and token
caches, high enough that the model doesn't latch onto pathological degenerate completions.
Three forces converge on that number:
- DeepSeek's context window comfortably fits this plus the system prompt + headroom for the response.
- 60 KB of legal text is around 25 pages — enough to cover the contracts the product targets, and above that marginal utility drops because the later pages of long agreements are almost always signature blocks, schedules, and exhibits.
- Token cost scales linearly with input size. Capping input bounds the worst-case cost per job.
For compare jobs we halve this so both documents fit into the single request.
Running a full analysis already yields all the data a risk-only or clauses-only view needs —
it's just a filter over highlights. The composable (useDocumentAnalysis.analysis) short-
circuits the trip to the server in that case, marking the result _derived: true for debugging
and saving both token spend and latency. The dedicated risk / clauses prompts still exist
for cases where the user opens the risk tab first without ever running full.
Browser-side rendering keeps the backend lean and, crucially, preserves a real text layer
on top of the PDF canvas. That text layer is what applyHighlights paints into. If we server-
rendered pages as images, we'd have to also ship word-level bounding boxes and paint
absolutely-positioned <mark> overlays — a much more fragile architecture.
For DOCX, mammoth converts to HTML in the browser, the result is run through a minimal
sanitizer (<script>/<style>/on*=/javascript: removed), and the same highlight injection
works on the HTML DOM. Plain text gets a <pre> and the same treatment.
Two reasons:
- A
TreeWalkerfiltered to text nodes lets us precisely skip<script>,<style>, and existing<mark>elements — preventing double-wrapping on re-render and preventing highlights from landing inside code-layer markup. - Splitting a text node and inserting a
<mark>is a local DOM operation — O(1) per highlight. UsingRange + extractContents + surroundContentsis equivalent but harder to reason about when the target substring spans text node boundaries.
The current implementation stops at the first match for a given highlight. This is intentional: LLM-returned excerpts are chosen to be unique, and matching every occurrence would produce duplicate-looking overlays for boilerplate phrases like "Governing Law".
The migration file database/migrations/2024_01_01_000001_create_document_analyses_table.php
creates document_analyses with the following rules:
| Column | Type | Rationale |
|---|---|---|
id |
uuid PK |
Non-sequential, safe to expose in URLs, no race in distributed dispatch |
chat_document_id |
FK → chat_documents cascadeOnDelete |
Deleting the doc removes its analyses — no orphan rows |
compare_document_id |
FK nullable nullOnDelete | A compare analysis survives losing the other side; it just loses the pointer |
user_id |
FK → users cascadeOnDelete |
Used for audit and per-user analytics via da_user_created index |
analysis_type |
string(20) default full |
Matches the kernel's Literal set |
status |
string(20) default pending |
Drives the state machine |
risk_score |
unsignedSmallInteger nullable |
Null while pending/processing; 0–100 when done |
risk_label |
string(30) nullable |
Pre-computed label for index-friendly sorting and rendering |
highlights |
json nullable |
Full array of DocumentHighlight objects |
compare_highlights |
json nullable |
Only populated for analysis_type=compare |
metrics |
json nullable |
Scoreboard payload (score, counts, parties, dates, summary) |
compare_summary |
text nullable |
Diff summary for compare runs |
error_message |
text nullable |
Populated only on failed; cleared on successful re-analyze |
timestamps |
created_at / updated_at |
Standard Eloquent |
The DocumentAnalysis Eloquent model casts the three JSON columns to PHP arrays and
risk_score to integer, and exposes convenience scopes:
scopeOfType($type, $compareDocumentId = null)— encodes the "is-this-exact-view" query as a reusable chain, handling the nullable compare pointer with awhen()clause.scopeTerminal()—where status in (done, failed), useful for cleanup and reporting.isReady()/isDone()/isFailed()/isPending()/isProcessing()— guard helpers used by both the controller and the worker.
Every analysis response — whether cached, freshly-computed, or derived on the client — conforms
to this shape (as enforced by DocumentAnalysisResource):
{
"id": "…uuid…",
"status": "done",
"analysis_type": "full",
"risk_score": 63,
"risk_label": "High Risk",
"highlights": [
{
"id": "h1",
"category": "risk_high",
"text": "The Service Provider may terminate this Agreement at any time without notice.",
"explanation": "Unilateral termination without notice exposes the client to abrupt service loss.",
"section": "Clause 12.1",
"page_hint": 4
}
],
"compare_highlights": null,
"metrics": {
"risk_score": 63,
"risk_label": "High Risk",
"clause_count": 18,
"obligation_count": 9,
"flagged_count": 5,
"parties": ["Acme LLC", "Beta Corp."],
"key_dates": ["2026-06-30", "effective upon signature"],
"summary": "Agreement is tilted in favour of the Service Provider..."
},
"compare_summary": null,
"error_message": null,
"created_at": "2026-04-14T10:20:00.000Z",
"updated_at": "2026-04-14T10:20:12.340Z"
}Notes:
error_messageis alwaysnullunless the row is infailed; the resource explicitly filters it so consumers don't mistake a stale message for a current error.- For
analysis_type=compare,highlightsholds Document A findings,compare_highlightsholds Document B findings, andcompare_summaryis the narrative overview.
DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
QUEUE_CONNECTION=redis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
KERNEL_URL=http://127.0.0.1:9002
KERNEL_SECRET=1234
SANCTUM_STATEFUL_DOMAINS=localhost:5173,localhost:3000
SESSION_DOMAIN=localhost
config/services.php exposes exactly two kernel knobs — URL and secret — so the rest of the
codebase only knows "where is the kernel" and "how do I prove I'm allowed to talk to it".
APP_ENV=local
DEEPSEEK_API_KEY=1234
DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
PYTHON_KERNEL_SECRET=1234
KERNEL_ALLOWED_ORIGINS=http://localhost:8000,http://localhost:5173
APP_ENV=production flips /docs, /redoc, and /openapi.json off — there is no route to the
schema in a production deploy.
VITE_API_BASE_URL=http://localhost:8000
In dev, Vite's server.proxy['/api'] also forwards to :8000, so the SPA can run at
:5173 without CORS contortions.
- Correlation id — each request to the kernel carries an
X-Request-ID(generated by Laravel's HTTP client or passed from upstream), which the kernel middleware binds into the loguru context so every log line during that request carries it. The header is echoed back so Laravel can log the same id on its side. - Structured Laravel logs — controllers and the job log
'[DocumentAnalysis] Dispatched','[ProcessDocumentAnalysis] Done', and'[ProcessDocumentAnalysis] Failed'withanalysis_id, type, highlight counts, and risk score. Enough to reconstruct the request path without opening the DB. - DeepSeek usage logging —
DeepSeekClient.generateemits prompt and completion token counts per call atDEBUGlevel, so cost accounting is agrepaway. - Failure visibility — unexpected kernel errors land in
logger.exception(full traceback) but the HTTP response is the sanitized message"Analysis failed. Please try again."— never the raw LLM output or exception text.
Where the system is designed to change:
- Add a new analysis type. Add the literal to
AnalyzeDocumentRequest.analysis_type, add a new_SYSTEM_Xconstant, register it in_SYSTEM_MAP, add the matching constant on the LaravelDocumentAnalysismodel, and whitelist it inDocumentAnalysisController::VALID_TYPES. No DB migration needed — the column is a plainstring(20). - Add a new highlight category. Extend the
HighlightCategoryLiteral, update the prompt rules, and add an entry toHIGHLIGHT_COLORSinfrontend/src/utils/documentHighlighter.js. The sidebar, tooltip, and overlay all key off that map. - Swap the LLM vendor. Replace
DeepSeekClientwith a new adapter that implements the samegenerate(messages, ...) -> strinterface. Nothing else moves. - Stream tokens instead of returning a blob.
DeepSeekClient.stream()is already in the codebase (SSE from OpenAI); the kernel would exposePOST /analyze-document/stream, the worker would buffer chunks and push partial results to theanalysisrow, and the frontend would render progressively. The polling loop would fall back cleanly. - Replace polling with Laravel Echo + Reverb. The resource shape is already a full snapshot of the row; a broadcast event carrying the resource payload on state change would make the UI push-driven without other code moves.
- Single-node queue.
QUEUE_CONNECTION=rediswith onelaravel-workercontainer is a small deployment. For higher throughput, scale the worker horizontally — the job is stateless. - Shared secret, not mTLS.
X-Kernel-Secretis appropriate for an internal Docker network. In a multi-tenant or zero-trust environment, upgrade to mTLS or a service mesh. - Verbatim-substring highlight injection. If the LLM paraphrases instead of quoting, the highlight will silently fail to render (the DOM walker won't find the substring). The system prompt aggressively tells the model "never paraphrase in the text field", but this remains a soft invariant. A future hardening step would fall back to fuzzy match (diff-match-patch) when the verbatim search fails.
- Compare truncation. Each document gets
_MAX_CHARS / 2 = 30 000chars in compare mode. Very long contracts may lose material from later sections. A future mitigation is a chunk-and-reduce pipeline (analyze chunks, then summarize chunk findings into a final report). - PDF text layer fidelity.
pdf.jspreserves a near-perfect text layer for modern PDFs but can struggle with scanned or image-only PDFs. The product assumes upstream text extraction (OCR) has already populatedChatDocument::extracted_textbeforeisReady()returns true.
OpenAct's document analysis engine is a small, disciplined system. Three tiers, each with one job. A queue between the HTTP layer and the AI so the user never waits on an open socket. A typed contract at every boundary so a malformed LLM response can't poison the UI. A Pinia cache and a Laravel idempotency check so the same click twice is free. And an in-browser highlight pass so the review doesn't just describe the document — it annotates it, exactly where the lawyer is already looking.