diff --git a/apps/api-go/chat.go b/apps/api-go/chat.go
index 351ff01..ace0854 100644
--- a/apps/api-go/chat.go
+++ b/apps/api-go/chat.go
@@ -31,6 +31,10 @@ type chatGuidePDFClient interface {
GuidePDF(context.Context, *http.Request, string) (int, []byte, error)
}
+type chatGuideHTMLClient interface {
+ GuideHTML(context.Context, *http.Request, string) (int, []byte, error)
+}
+
func (s *Server) createChatQuery(response http.ResponseWriter, request *http.Request, principal Principal) {
idempotencyKey, ok := chatIdempotencyKey(request)
if !ok {
@@ -96,6 +100,29 @@ func (s *Server) getChatGuidePDF(response http.ResponseWriter, request *http.Req
_, _ = response.Write(body)
}
+func (s *Server) getChatGuideHTML(response http.ResponseWriter, request *http.Request, principal Principal) {
+ queryID, err := chatPathID(request)
+ if err != nil {
+ writeAPIError(response, request, http.StatusUnprocessableEntity, "INVALID_REQUEST", err.Error(), false)
+ return
+ }
+ client, ok := s.chatClient.(chatGuideHTMLClient)
+ if !ok {
+ writeAPIError(response, request, http.StatusServiceUnavailable, "INGESTION_UNAVAILABLE", "guide HTML service is not configured", true)
+ return
+ }
+ status, body, err := client.GuideHTML(request.Context(), chatInternalRequest(request, principal), queryID)
+ if err != nil || status < http.StatusOK || status >= http.StatusMultipleChoices || len(body) == 0 {
+ writeAPIError(response, request, http.StatusBadGateway, "INGESTION_UNAVAILABLE", "guide HTML is unavailable", true)
+ return
+ }
+ response.Header().Set("Content-Type", "text/html; charset=utf-8")
+ response.Header().Set("Content-Disposition", `attachment; filename="autodata-repair-guide.html"`)
+ response.Header().Set("Cache-Control", "private, no-store")
+ response.WriteHeader(status)
+ _, _ = response.Write(body)
+}
+
func (s *Server) selectChatVehicle(response http.ResponseWriter, request *http.Request, principal Principal) {
queryID, err := chatPathID(request)
if err != nil {
diff --git a/apps/api-go/chat_test.go b/apps/api-go/chat_test.go
index 607ef78..b338ea9 100644
--- a/apps/api-go/chat_test.go
+++ b/apps/api-go/chat_test.go
@@ -40,6 +40,12 @@ type fakeChatClient struct {
guidePDFReply []byte
guidePDFErr error
+ guideHTMLCalls int
+ guideHTMLID string
+ guideHTMLStatus int
+ guideHTMLReply []byte
+ guideHTMLErr error
+
eventsCalls int
eventsID string
eventsLastID string
@@ -82,6 +88,12 @@ func (f *fakeChatClient) GuidePDF(_ context.Context, request *http.Request, quer
return f.guidePDFStatus, f.guidePDFReply, f.guidePDFErr
}
+func (f *fakeChatClient) GuideHTML(_ context.Context, request *http.Request, queryID string) (int, []byte, error) {
+ f.guideHTMLCalls++
+ f.guideHTMLID = queryID
+ return f.guideHTMLStatus, f.guideHTMLReply, f.guideHTMLErr
+}
+
func (f *fakeChatClient) Events(_ context.Context, request *http.Request, queryID, lastEventID string) (io.ReadCloser, error) {
f.eventsCalls++
f.eventsReq = request.Clone(request.Context())
@@ -224,6 +236,31 @@ func TestChatGuidePDFForwardsQueryIDAndReturnsPrivatePDF(t *testing.T) {
}
}
+func TestChatGuideHTMLForwardsQueryIDAndReturnsPrivateHTML(t *testing.T) {
+ client := &fakeChatClient{guideHTMLStatus: http.StatusOK, guideHTMLReply: []byte("")}
+ server := newChatServer(client)
+ request := httptest.NewRequest(http.MethodGet, "/chat/queries/q-guide/guide.html", nil)
+ response := httptest.NewRecorder()
+
+ server.Handler().ServeHTTP(response, request)
+
+ if response.Code != http.StatusOK || string(response.Body.Bytes()) != string(client.guideHTMLReply) {
+ t.Fatalf("status/body = %d/%q, want 200/html", response.Code, response.Body.Bytes())
+ }
+ if client.guideHTMLCalls != 1 || client.guideHTMLID != "q-guide" {
+ t.Fatalf("guide HTML forwarding = %d/%q", client.guideHTMLCalls, client.guideHTMLID)
+ }
+ if response.Header().Get("Content-Type") != "text/html; charset=utf-8" {
+ t.Fatalf("content type = %q, want text/html; charset=utf-8", response.Header().Get("Content-Type"))
+ }
+ if response.Header().Get("Content-Disposition") != `attachment; filename="autodata-repair-guide.html"` {
+ t.Fatalf("content disposition = %q", response.Header().Get("Content-Disposition"))
+ }
+ if response.Header().Get("Cache-Control") != "private, no-store" {
+ t.Fatalf("cache control = %q, want private, no-store", response.Header().Get("Cache-Control"))
+ }
+}
+
func TestChatSelectionRequiresAnOption(t *testing.T) {
client := &fakeChatClient{selectStatus: http.StatusOK, selectReply: []byte(`{"status":"processing"}`)}
server := newChatServer(client)
diff --git a/apps/api-go/dashboard/app.js b/apps/api-go/dashboard/app.js
index 4b3b755..ac96956 100644
--- a/apps/api-go/dashboard/app.js
+++ b/apps/api-go/dashboard/app.js
@@ -468,6 +468,7 @@
? "Select the matching vehicle above to continue."
: "Procedure steps are not available yet.")]);
$("review-badge").hidden = true;
+ $("procedure-html").hidden = true;
$("procedure-pdf").hidden = true;
$("procedure-visuals").hidden = true;
$("procedure-evidence").hidden = true;
@@ -479,6 +480,10 @@
? "Here’s the vehicle-matched step-by-step procedure. Follow the steps in order."
: "Some required repair instructions are still missing. Review the notice before using this preview.");
setText("procedure-technical-generation", generation ? `Generated by ${generation}` : "Generation source was not returned by the API.");
+ const html = isObject(answer.html) && answer.html.ready === true ? answer.html : null;
+ const htmlLink = $("procedure-html");
+ htmlLink.hidden = !html;
+ if (html) htmlLink.href = text(html.url);
const pdf = isObject(answer.pdf) && answer.pdf.ready === true ? answer.pdf : null;
const pdfLink = $("procedure-pdf");
pdfLink.hidden = !pdf;
diff --git a/apps/api-go/dashboard/index.html b/apps/api-go/dashboard/index.html
index 8159ced..53831f2 100644
--- a/apps/api-go/dashboard/index.html
+++ b/apps/api-go/dashboard/index.html
@@ -128,6 +128,7 @@
No procedure yet
UNREVIEWED
A structured procedure will appear here.
+ Download complete guide as HTML ↗Download complete guide as PDF ↗
diff --git a/apps/api-go/dashboard/styles.css b/apps/api-go/dashboard/styles.css
index 91f84f0..355287b 100644
--- a/apps/api-go/dashboard/styles.css
+++ b/apps/api-go/dashboard/styles.css
@@ -260,8 +260,9 @@ textarea:focus { border-color: var(--signal); box-shadow: 0 0 0 3px rgb(242 164
.step-visuals { grid-column: 2; display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 8px; margin-top: 9px; }
.step-visual { margin: 0; border: 1px solid var(--line); background: var(--surface); }
.step-visual img { display: block; width: 100%; max-height: 280px; object-fit: contain; }
-.pdf-button { display: inline-flex; width: fit-content; gap: 9px; margin: 0 0 14px; border: 1px solid var(--cool); padding: 8px 10px; color: var(--cool); background: transparent; font-family: var(--mono); font-size: 10px; text-decoration: none; }
-.pdf-button:hover { border-color: var(--signal); color: var(--signal); }
+.guide-button, .pdf-button { display: inline-flex; width: fit-content; gap: 9px; margin: 0 0 14px; border: 1px solid var(--cool); padding: 8px 10px; color: var(--cool); background: transparent; font-family: var(--mono); font-size: 10px; text-decoration: none; }
+.html-button { border-color: var(--signal); color: var(--signal); }
+.guide-button:hover, .pdf-button:hover { border-color: var(--signal); color: var(--signal); }
.step-meta { grid-column: 2; display: flex; flex-wrap: wrap; gap: 8px 13px; margin-top: 7px; color: var(--faint); font-family: var(--mono); font-size: 9px; line-height: 1.4; }
.field-label { margin-right: 4px; color: var(--cool); font-weight: 500; }
.safety-note .field-label { color: var(--danger); }
diff --git a/apps/api-go/dashboard_test.go b/apps/api-go/dashboard_test.go
index 1761f71..38f5d2b 100644
--- a/apps/api-go/dashboard_test.go
+++ b/apps/api-go/dashboard_test.go
@@ -25,6 +25,8 @@ func TestDashboardRouteServesChatbotShell(t *testing.T) {
"class=\"panel answer-panel\"",
"id=\"vehicle-options\"",
"id=\"procedure-steps\"",
+ "id=\"procedure-html\"",
+ "Download complete guide as HTML",
"id=\"parts-list\"",
"id=\"worker-terminal\"",
"id=\"consumer-summary\"",
@@ -90,6 +92,8 @@ func TestDashboardRouteServesJavaScriptAsset(t *testing.T) {
"required_hours",
"recommended_hours",
"Generated by",
+ "answer.html",
+ "procedure-html",
"UNREVIEWED",
"worker_stream",
"renderConsumerSummary",
diff --git a/apps/api-go/ingestion_http.go b/apps/api-go/ingestion_http.go
index 7ad605b..3ef0a91 100644
--- a/apps/api-go/ingestion_http.go
+++ b/apps/api-go/ingestion_http.go
@@ -15,7 +15,9 @@ import (
)
const maxIngestionProxyBytes = 8 << 20
+const maxGuideHTMLBytes = 32 << 20
const maxGuidePDFAttempts = 3
+const maxGuideHTMLAttempts = 3
const guidePDFRetryDelay = 250 * time.Millisecond
const maxChatGetAttempts = 3
const chatGetRetryDelay = 250 * time.Millisecond
@@ -138,6 +140,40 @@ func (c *HTTPIngestionClient) GuidePDF(ctx context.Context, incoming *http.Reque
return 0, nil, fmt.Errorf("guide PDF retry limit reached")
}
+func (c *HTTPIngestionClient) GuideHTML(ctx context.Context, incoming *http.Request, queryID string) (int, []byte, error) {
+ path, err := chatInternalPath(queryID, "guide.html")
+ if err != nil {
+ return 0, nil, err
+ }
+ for attempt := 0; attempt < maxGuideHTMLAttempts; attempt++ {
+ outgoing, err := c.newInternalRequest(requestContext(ctx, incoming), incoming, http.MethodGet, path, nil, "")
+ if err != nil {
+ return 0, nil, err
+ }
+ outgoing.Header.Set("Accept", "text/html")
+ result, err := c.client.Do(outgoing)
+ if err != nil {
+ return 0, nil, err
+ }
+ body, readErr := io.ReadAll(io.LimitReader(result.Body, maxGuideHTMLBytes+1))
+ result.Body.Close()
+ if readErr != nil || len(body) > maxGuideHTMLBytes {
+ return 0, nil, fmt.Errorf("guide HTML exceeds the configured limit")
+ }
+ if !isTransientGuidePDFStatus(result.StatusCode) || attempt == maxGuideHTMLAttempts-1 {
+ return result.StatusCode, body, nil
+ }
+ timer := time.NewTimer(guidePDFRetryDelay)
+ select {
+ case <-requestContext(ctx, incoming).Done():
+ timer.Stop()
+ return 0, nil, requestContext(ctx, incoming).Err()
+ case <-timer.C:
+ }
+ }
+ return 0, nil, fmt.Errorf("guide HTML retry limit reached")
+}
+
func isTransientGuidePDFStatus(status int) bool {
return status == http.StatusBadGateway || status == http.StatusServiceUnavailable || status == http.StatusGatewayTimeout
}
diff --git a/apps/api-go/ingestion_http_test.go b/apps/api-go/ingestion_http_test.go
index f4fac24..6a56e8d 100644
--- a/apps/api-go/ingestion_http_test.go
+++ b/apps/api-go/ingestion_http_test.go
@@ -182,6 +182,43 @@ func TestHTTPIngestionClientRetriesTransientGuidePDFResponse(t *testing.T) {
}
}
+func TestHTTPIngestionClientRetriesTransientGuideHTMLResponse(t *testing.T) {
+ requestCount := 0
+ server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
+ requestCount++
+ if request.URL.Path != "/v1/chat/queries/q-html/guide.html" {
+ t.Fatalf("path = %q, want guide HTML path", request.URL.Path)
+ }
+ if requestCount == 1 {
+ response.WriteHeader(http.StatusServiceUnavailable)
+ _, _ = response.Write([]byte(`{"error":"transient"}`))
+ return
+ }
+ response.Header().Set("Content-Type", "text/html; charset=utf-8")
+ _, _ = response.Write([]byte("guide"))
+ }))
+ defer server.Close()
+
+ client, err := NewHTTPIngestionClient(server.URL, "", time.Second)
+ if err != nil {
+ t.Fatal(err)
+ }
+ status, body, err := client.GuideHTML(
+ context.Background(),
+ httptest.NewRequest(http.MethodGet, "/chat/queries/q-html/guide.html", nil),
+ "q-html",
+ )
+ if err != nil {
+ t.Fatalf("guide HTML failed: %v", err)
+ }
+ if status != http.StatusOK || string(body) != "guide" {
+ t.Fatalf("status/body = %d/%q, want 200/HTML", status, body)
+ }
+ if requestCount != 2 {
+ t.Fatalf("request count = %d, want one retry", requestCount)
+ }
+}
+
func TestHTTPIngestionClientRetriesTransientChatQueryResponse(t *testing.T) {
requestCount := 0
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
diff --git a/apps/api-go/main.go b/apps/api-go/main.go
index 04d3b3e..86220b7 100644
--- a/apps/api-go/main.go
+++ b/apps/api-go/main.go
@@ -169,6 +169,7 @@ func (s *Server) Handler() http.Handler {
mux.Handle("POST /chat/queries", s.requireRole("dataset_viewer", s.createChatQuery))
mux.Handle("GET /chat/queries/{id}", s.requireRole("dataset_viewer", s.getChatQuery))
mux.Handle("GET /chat/queries/{id}/guide.pdf", s.requireRole("dataset_viewer", s.getChatGuidePDF))
+ mux.Handle("GET /chat/queries/{id}/guide.html", s.requireRole("dataset_viewer", s.getChatGuideHTML))
mux.Handle("POST /chat/queries/{id}/selections", s.requireRole("dataset_viewer", s.selectChatVehicle))
mux.Handle("GET /chat/queries/{id}/events", s.requireRole("dataset_viewer", s.streamChatEvents))
mux.Handle("GET /dataset-requests/{id}", s.requireRole("dataset_viewer", s.getDatasetRequest))
diff --git a/docs/agents/records/2026-09-15-html-guide-and-silverado-label.json b/docs/agents/records/2026-09-15-html-guide-and-silverado-label.json
new file mode 100644
index 0000000..3176a10
--- /dev/null
+++ b/docs/agents/records/2026-09-15-html-guide-and-silverado-label.json
@@ -0,0 +1,30 @@
+{
+ "goal": "Make standalone HTML repair guides the preferred artifact and correct malformed AutoAPI Silverado vehicle labels",
+ "plan_ref": "docs/superpowers/plans/2026-09-15-html-guide-and-silverado-label.md",
+ "issue_ref": "https://github.com/lucronn/autodata/issues/96",
+ "project_ref": "https://github.com/users/lucronn/projects/8",
+ "repository_doc_refs": [
+ "docs/architecture/guide-artifacts.md",
+ "docs/architecture/consumer-repair-guides.md",
+ "docs/verification/consumer-review-runbook.md"
+ ],
+ "todo": [
+ "Add failing HTML artifact and Silverado identity regressions",
+ "Implement standalone HTML rendering with base64-embedded figures and preserve PDF compatibility",
+ "Add worker, internal HTTP, Go API, dashboard, and consumer verification support",
+ "Sanitize provider prose labels without changing valid detailed vehicle labels",
+ "Run focused and full verification and record exact implementation and CI evidence",
+ "Update Issue #96 and Project #8 with the verified delivery state"
+ ],
+ "base_sha": "b2cdc5a",
+ "implementation_sha": "d63ab7b",
+ "verification": {
+ "python": "426 passed, 3 skipped, 12 subtests passed",
+ "go": "go test ./... -count=1 passed in apps/api-go",
+ "javascript": "node --check apps/api-go/dashboard/app.js passed",
+ "diff": "git diff --check passed",
+ "live_silverado": "pending service restart; not claimed"
+ },
+ "status": "synchronized",
+ "updated_at": "2026-09-15T18:41:51Z"
+}
diff --git a/docs/architecture/consumer-repair-guides.md b/docs/architecture/consumer-repair-guides.md
index 9ce72e8..29667f6 100644
--- a/docs/architecture/consumer-repair-guides.md
+++ b/docs/architecture/consumer-repair-guides.md
@@ -4,9 +4,9 @@ Issue: https://github.com/lucronn/autodata/issues/89
Project: https://github.com/users/lucronn/projects/8
Plan: ../superpowers/plans/2026-09-14-consumer-repair-guides.md
-AutoData returns an illustrated DIY guide in chat and a matching PDF when required content is complete. Both providers contribute vehicle-matched evidence. Retrieve supporting removal, installation, specifications, sealing, timing, fluids and checks rather than replacing them with generic instructions. Shared work is consolidated by mechanical prerequisites. Model-generated wording cannot create unsupported specifications or discard warnings. The consumer view omits sourcing/generation commentary; provenance and review status remain internal data and the existing review notice remains visible. Completeness does not imply technician approval.
+AutoData returns an illustrated DIY guide in chat and a self-contained HTML download when required content is complete; the matching PDF remains available for compatibility. HTML is the preferred artifact because it carries prepared figures inline and opens without a second asset request. Both providers contribute vehicle-matched evidence. Retrieve supporting removal, installation, specifications, sealing, timing, fluids and checks rather than replacing them with generic instructions. Shared work is consolidated by mechanical prerequisites. Model-generated wording cannot create unsupported specifications or discard warnings. The consumer view omits sourcing/generation commentary; provenance and review status remain internal data and the existing review notice remains visible. Completeness does not imply technician approval.
-The additive guide answer includes revision, applicability, preparation, ordered phases/steps, figure associations, torque references, completion state and actionable gaps. Existing procedure/quote consumers remain compatible. Previews cannot produce final PDF downloads. Authorized downloads represent exactly the displayed immutable revision, including its images. Provider or media failures remain visible gaps, not fabricated completions.
+The additive guide answer includes revision, applicability, preparation, ordered phases/steps, figure associations, torque references, completion state and actionable gaps. Existing procedure/quote consumers remain compatible. Previews cannot produce final HTML or PDF downloads. Authorized downloads represent exactly the displayed immutable revision, including its prepared images. The HTML and PDF metadata carry the same revision ID. Provider or media failures remain visible gaps, not fabricated completions.
Delivery status: the bounded second-provider connector, dependency-aware guide
composition, revision-matched PDF renderer, and authenticated delivery path are
@@ -31,4 +31,21 @@ pages plus timing-belt prerequisites when required by the pump job. Ordered
HTML text and returned figures are converted into grouped consumer steps; the
installation instructions remain separate from removal and retain their
sealing, torque, timing, refill, and final-check details. A complete guide is
-served as a revision-keyed PDF through the authenticated Go API proxy.
+served as revision-keyed HTML and PDF artifacts through the authenticated Go
+API proxy. The dashboard makes HTML primary and PDF secondary.
+
+## Artifact and identity rules
+
+The worker prepares each figure once, then the HTML renderer embeds every
+prepared image as a validated `data:image/*;base64,...` URI inside standalone
+HTML with inline CSS. The public answer exposes only permitted metadata; raw
+provider HTML and internal evidence IDs remain in the authorized persisted
+record. The consumer verifier checks the HTML document, image count, valid
+base64 payloads, absence of remote assets, and revision parity independently
+from the PDF check.
+
+At the AutoAPI boundary, canonical year, make, model, drivetrain, and engine
+fields win over malformed provider prose. For example, a provider value such
+as `For A Chevrolet` must produce the consumer heading
+`1999 Chevrolet Silverado 1500 2WD 5.3L`, never
+`1999 For A Chevrolet Silverado 1500 2WD 5.3L`.
diff --git a/docs/architecture/guide-artifacts.md b/docs/architecture/guide-artifacts.md
new file mode 100644
index 0000000..f434bb9
--- /dev/null
+++ b/docs/architecture/guide-artifacts.md
@@ -0,0 +1,45 @@
+# Repair-guide artifact contract
+
+**Goal:** Serve a self-contained HTML repair guide as the fastest, most portable consumer artifact while preserving the existing PDF download for compatibility. Correct provider vehicle labels at the source boundary so canonical identity is never replaced by provider prose.
+
+**Plan:** `docs/superpowers/plans/2026-09-15-html-guide-and-silverado-label.md`
+
+**Project:** https://github.com/users/lucronn/projects/8
+
+**Issue:** https://github.com/lucronn/autodata/issues/96
+
+## Decision
+
+The persisted procedure and immutable revision remain the single source of guide content. The authorized service prepares source figures once, then renders either:
+
+- HTML: a standalone document with inline CSS and every prepared figure embedded as a base64 `data:` URI. It is the preferred dashboard/download output and does not require a network connection after delivery.
+- PDF: the existing revision-matched compatibility artifact, retained for established consumers.
+
+Both artifacts are gated by the same complete-guide contract and carry the same revision ID, source watermark, warnings, and review label. The persisted guide retains internal evidence linkage for auditability; public artifact serialization exposes only permitted review/evidence metadata and never raw provider HTML or internal provenance IDs. HTML rendering never fetches a remote image and never invents missing content.
+
+Vehicle applicability is derived from canonical year, make, model, drivetrain, and engine fields when a provider candidate has a malformed prose label. A candidate value such as `For A Chevrolet` is normalized to `Chevrolet` with an alias trail; the consumer heading for the supplied Silverado example is `1999 Chevrolet Silverado 1500 2WD 5.3L`.
+
+## Concrete todo
+
+- [x] Synchronize the Issue, Project item, plan, canonical docs, and machine-checked pre-implementation record at one checkpoint SHA.
+- [x] Add failing HTML-artifact and Silverado-label tests.
+- [x] Implement HTML rendering, worker caching, internal delivery, Go proxying, answer metadata, dashboard preference, and consumer verification.
+- [x] Implement provider-label sanitization without changing valid detailed vehicle labels.
+- [x] Run focused/full verification and record exact evidence here and in GitHub.
+
+## Boundaries
+
+No source data, credentials, or user-owned runtime artifacts are part of this delivery. The GitHub Project is a synchronized delivery index; this document and the linked implementation plan are the normative technical record. Verification evidence is added below after the final local test run.
+
+## Local verification checkpoint
+
+Implementation commit `d63ab7b` passed the applicable local checks:
+
+- `426 passed, 3 skipped, 12 subtests passed` across the Python worker,
+ consumer, and contract suites.
+- `go test ./... -count=1` passed in `apps/api-go`.
+- `node --check apps/api-go/dashboard/app.js` passed.
+- `git diff --check` passed.
+
+The live/local Silverado chat run and HTML/PDF parity check remain a post-restart
+acceptance step; no live result is claimed by this checkpoint.
diff --git a/docs/superpowers/plans/2026-09-15-html-guide-and-silverado-label.md b/docs/superpowers/plans/2026-09-15-html-guide-and-silverado-label.md
new file mode 100644
index 0000000..1b57155
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-15-html-guide-and-silverado-label.md
@@ -0,0 +1,79 @@
+# HTML Guide Artifact and Silverado Identity Label Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Make a self-contained HTML repair guide with base64-embedded figures the preferred downloadable artifact while preserving the existing PDF contract, and correct AutoAPI vehicle normalization so the 1999 Chevrolet Silverado 1500 2WD 5.3L is labeled with its canonical identity.
+
+**Architecture:** Keep guide composition, authorization, revision matching, and source evidence unchanged. Add an additive HTML renderer and authenticated delivery route that reuses the same immutable guide revision and prepared image bytes as PDF. Sanitize provider vehicle identity at the AutoAPI candidate boundary and build applicability from canonical fields when a provider label contains prose. Maintain the dashboard’s existing PDF link as a secondary compatibility path.
+
+**Tech Stack:** Python standard-library HTML rendering with base64 and HTML escaping, existing Python ingestion service and AutoAPI Two connector, Go API proxy, vanilla dashboard JavaScript/HTML/CSS, unittest/pytest, Go tests, and existing consumer contract evaluator.
+
+**Spec:** The HTML artifact must be standalone, render without network access, embed every prepared guide figure as a `data:` URI, expose the guide revision/source watermark/review state, and be available only for complete immutable guides. The PDF endpoint remains supported. A provider value such as `For A Chevrolet` must never become the canonical make or consumer heading; the target label for the supplied example is `1999 Chevrolet Silverado 1500 2WD 5.3L`.
+
+## Global Constraints
+
+- Follow `docs/agents/pre-implementation-gate.md`; implementation begins only after the synchronized record is committed at the pinned base SHA and machine preflight passes.
+- Do not modify or stage the user-owned `sample data/`, `output/`, or `tmp/` directories.
+- Do not expose, persist, or add credentials or provider secrets.
+- Do not change the existing PDF authorization, revision, completeness, evidence, or cache semantics except where shared preparation is safely reused.
+- Do not fetch remote images from the HTML renderer; image bytes must be fetched once by the authorized service preparation path and embedded locally.
+- Do not invent a new source of truth: the canonical guide remains the persisted procedure/revision and the GitHub Project remains an index linked to repository documentation.
+
+## Task 1: Synchronize planning and tracking
+
+- [x] Create the GitHub Issue for this plan, link it to Project #8, and apply the issue labels `area:api`, `area:fast-lane`, `area:deep-lane`, `type:feature`, `type:source-ingestion`, `priority:p1`, and `risk:high`.
+- [x] Update the Issue body with this plan path, canonical document path, exact acceptance contract, and the concrete todo list below.
+- [x] Add the Issue to Project #8, set Status to `Ready`, and populate Source or dependency reference with the Issue, plan, and canonical document URLs.
+- [x] Add the synchronized pre-implementation record under `docs/agents/records/` pinned to the checkpoint commit and run `scripts/autonomy/pre_implementation.py` validation.
+
+## Task 2: Establish failing tests and traceable identity behavior
+
+- [x] Add a regression test in `workers/ingestion-python/tests/test_autoapitwo_guide.py` for a provider candidate whose make/description contains `For A Chevrolet`, asserting canonical candidate fields and applicability equal `1999 Chevrolet Silverado 1500 2WD 5.3L`.
+- [x] Add focused HTML renderer tests in `workers/ingestion-python/tests/test_guide_html.py` for standalone output, escaped text, base64 image embedding, meaningful-caption preservation, generic-caption omission, and rejection of incomplete guides.
+- [x] Add API, internal ingestion HTTP, chat-service, dashboard, and consumer-contract test cases for the additive HTML artifact and compatibility PDF path.
+
+## Task 3: Implement the standalone HTML guide artifact
+
+- [x] Add `workers/ingestion-python/src/autodata_ingestion/guide_html.py` with a deterministic `render_guide_html` function that emits escaped inline HTML/CSS, guide metadata, preparation, warnings, ordered steps, evidence references, and `data:{media_type};base64,...` images.
+- [x] Extend `workers/ingestion-python/src/autodata_ingestion/chat_service.py` with revision-keyed HTML caching and `render_chat_guide_html`, reusing the existing authorized image preparation and complete-guide checks without changing PDF behavior.
+- [x] Add HTML metadata to the public chat answer while retaining the existing PDF metadata and revision IDs.
+- [x] Add `/v1/chat/queries/{id}/guide.html` to the ingestion service and proxy it through the Go API as `text/html; charset=utf-8` with a private attachment filename.
+- [x] Make the dashboard present the HTML guide as the primary action and the PDF as a secondary compatibility action, with clear unavailable-state behavior.
+- [x] Extend `scripts/dev/consumer_agent.py` and its tests to validate HTML readiness, revision matching, standalone bytes, and embedded-image integrity without weakening PDF checks.
+
+## Task 4: Correct provider vehicle identity normalization
+
+- [x] Normalize provider candidate make values before constructing the consumer label, stripping only a leading prose prefix such as `For A` when the remaining value is a make and preserving an alias/evidence trail.
+- [x] Ensure applicability is composed from canonical year, make, model, drivetrain, and engine fields when the provider label is malformed; do not copy a malformed label over canonical identity.
+- [x] Preserve exact valid provider labels for compatible existing cases, including the detailed RAV4 label covered by current tests.
+
+## Task 5: Verify, document, and deliver
+
+- [x] Run focused worker, Go API, dashboard, and consumer-contract tests, then the complete applicable test suites and `git diff --check`.
+- [x] Exercise a representative complete guide offline and verify that removing network access after preparation still leaves a renderable HTML document with embedded figures.
+- [ ] Run the live/local Silverado example through the chat path, verify the canonical heading and HTML/PDF revision parity, and record measured evidence in the plan and canonical guide document.
+- [x] Update `docs/architecture/consumer-repair-guides.md`, `docs/verification/consumer-review-runbook.md`, and `docs/wiki/Getting-Started.md` to make HTML the preferred artifact while documenting PDF compatibility and the identity-label rule.
+- [ ] Update the GitHub Issue and Project item with exact implementation SHA, tests, and remaining review status; push the synchronized branch and merge only after required CI and independent review gates pass.
+
+## Acceptance Contract
+
+- `GET /chat/queries/{id}/guide.html` returns a complete, revision-matched, authorized standalone HTML document whose figures are base64 embedded and whose text is HTML escaped.
+- `GET /chat/queries/{id}/guide.pdf` continues returning the existing PDF contract.
+- The chat answer exposes the HTML and PDF links with the same revision ID; HTML is the default dashboard action.
+- The Silverado example renders `1999 Chevrolet Silverado 1500 2WD 5.3L`, never `1999 For A Chevrolet Silverado 1500 2WD 5.3L`.
+- Incomplete guides remain unavailable for final artifacts, source evidence and review status remain visible, and user-owned untracked directories remain untouched.
+
+**Tracking:** [Issue #96](https://github.com/lucronn/autodata/issues/96) and [Project #8](https://github.com/users/lucronn/projects/8).
+
+## Local verification checkpoint
+
+Implementation commit `d63ab7b` passed:
+
+- `PYTHONPATH=workers/ingestion-python/src python3 -m pytest -q workers/ingestion-python/tests scripts/dev/test_consumer_agent.py scripts/contracts/test_chat_quote_contract.py scripts/contracts/test_contracts.py` — `426 passed, 3 skipped, 12 subtests passed`.
+- `go test ./... -count=1` from `apps/api-go` — passed.
+- `node --check apps/api-go/dashboard/app.js` — passed.
+- `git diff --check` — passed.
+
+The live/local Silverado chat run and HTML/PDF parity check are still pending a
+restart of the running service on this branch; this plan does not claim that
+runtime result yet.
diff --git a/docs/verification/consumer-review-runbook.md b/docs/verification/consumer-review-runbook.md
index 79c67e0..9c432b1 100644
--- a/docs/verification/consumer-review-runbook.md
+++ b/docs/verification/consumer-review-runbook.md
@@ -40,8 +40,8 @@ selection.
## Report and issue handling
-The report contains the consumer-visible query/answer projection, response and
-PDF hashes, dimension evidence, findings, and issue actions. It deliberately
+The report contains the consumer-visible query/answer projection, response,
+HTML, and PDF hashes, dimension evidence, findings, and issue actions. It deliberately
omits raw provider HTML, source URLs, evidence internals, credentials,
authorization material, cookies, and arbitrary headers. Retain reports in the
approved run-artifact store; do not commit live responses to the source tree.
@@ -52,9 +52,13 @@ response is incomplete or ambiguous and must be dispositioned before release.
the dependency is restored. Re-run the same case after a fix and link the new
report hash in the issue before closing it.
-The PDF link is part of the consumer contract. A guide that advertises a ready
-PDF but returns a transient 502/503/504 is a release-blocking finding until the
-proxy's bounded retry behavior is verified by a fresh cold and warm matrix.
+The HTML link is the preferred consumer artifact. A complete guide that
+advertises ready HTML must return standalone HTML with inline CSS and every
+prepared figure as a valid base64 `data:image/*` URI, with no remote asset
+references and the same revision ID as the chat answer. A complete guide that
+advertises a ready PDF must still return a revision-matched PDF; a transient
+502/503/504 is a release-blocking finding until the proxy's bounded retry
+behavior is verified by a fresh cold and warm matrix.
The API-to-ingestion proxy default is 120 seconds because provider-backed PDFs
may require multiple figure reads. It retries only transient 502/503/504
@@ -82,7 +86,11 @@ non-retryable failures are not retried.
The public answer must remain below the API proxy response budget. Internal
evidence and provenance metadata is not consumer content and must be removed
from the serialized public projection before the 8 MB boundary; procedure
-steps, figure URLs, review state, and PDF revision identifiers must remain.
+steps, figure URLs, review state, and HTML/PDF revision identifiers must remain.
+
+For an incomplete or failed guide, neither final artifact may be advertised as
+ready. A deep-lane failure may leave HTML/PDF unavailable while the already
+viewable response remains accessible; it must not revoke that viewable revision.
Latest cold verification at implementation `8a2a7ee` passes 4/4. The report is
`tmp/consumer-review-live/post-public-compaction-cold-v2/consumer-review-73d119083fa152737b002ee1.json`
diff --git a/docs/wiki/Getting-Started.md b/docs/wiki/Getting-Started.md
index c51ff70..6ddf69b 100644
--- a/docs/wiki/Getting-Started.md
+++ b/docs/wiki/Getting-Started.md
@@ -43,8 +43,10 @@ request such as:
Choose the exact vehicle match when prompted. The guide keeps removal and
installation together, includes timing-belt access and final fluid/leak
checks when required by the returned procedure, and shows each available
-figure beside its step. The PDF link appears only after the required procedure
-coverage and figures are complete. The full local configuration and source
+figure beside its step. The HTML download appears first only after the required
+procedure coverage and figures are complete; it contains the prepared figures
+inline. The PDF link remains available as a compatibility fallback for the same
+revision. The full local configuration and source
boundary are documented in the [repository README](https://github.com/lucronn/autodata#illustrated-diy-repair-guides).
## Read the full workflow
diff --git a/packages/contracts/contract.json b/packages/contracts/contract.json
index 42f9615..817e4ea 100644
--- a/packages/contracts/contract.json
+++ b/packages/contracts/contract.json
@@ -158,7 +158,9 @@
"updated_at": {"type": "string"},
"worker_stream": {"type": "array"},
"source_watermark": {"type": "string", "optional": true},
- "revision_id": {"type": "uuid", "nullable": true, "optional": true}
+ "revision_id": {"type": "uuid", "nullable": true, "optional": true},
+ "pdf": {"type": "object", "nullable": true, "optional": true},
+ "html": {"type": "object", "nullable": true, "optional": true}
}
},
"chat_query": {
diff --git a/packages/contracts/go/contracts.go b/packages/contracts/go/contracts.go
index f2dbef2..db6cbab 100644
--- a/packages/contracts/go/contracts.go
+++ b/packages/contracts/go/contracts.go
@@ -229,6 +229,8 @@ type ChatAnswer struct {
WorkerStream []WorkerProgressEvent `json:"worker_stream"`
SourceWatermark string `json:"source_watermark,omitempty"`
RevisionID *string `json:"revision_id,omitempty"`
+ PDF map[string]any `json:"pdf,omitempty"`
+ HTML map[string]any `json:"html,omitempty"`
}
type ChatQuery struct {
diff --git a/packages/contracts/python/autodata_contracts/contracts.py b/packages/contracts/python/autodata_contracts/contracts.py
index af1b3ca..6d3fdd8 100644
--- a/packages/contracts/python/autodata_contracts/contracts.py
+++ b/packages/contracts/python/autodata_contracts/contracts.py
@@ -253,6 +253,8 @@ class ChatAnswer:
worker_stream: list[WorkerProgressEvent]
source_watermark: str | None = None
revision_id: str | None = None
+ pdf: dict[str, Any] | None = None
+ html: dict[str, Any] | None = None
@dataclass(frozen=True)
diff --git a/scripts/contracts/generate.py b/scripts/contracts/generate.py
index 210050a..2a8210a 100644
--- a/scripts/contracts/generate.py
+++ b/scripts/contracts/generate.py
@@ -332,6 +332,8 @@ def render_go(contract: dict[str, Any]) -> str:
WorkerStream []WorkerProgressEvent `json:"worker_stream"`
SourceWatermark string `json:"source_watermark,omitempty"`
RevisionID *string `json:"revision_id,omitempty"`
+ PDF map[string]any `json:"pdf,omitempty"`
+ HTML map[string]any `json:"html,omitempty"`
}
type ChatQuery struct {
@@ -652,6 +654,8 @@ class ChatAnswer:
worker_stream: list[WorkerProgressEvent]
source_watermark: str | None = None
revision_id: str | None = None
+ pdf: dict[str, Any] | None = None
+ html: dict[str, Any] | None = None
@dataclass(frozen=True)
diff --git a/scripts/contracts/test_chat_quote_contract.py b/scripts/contracts/test_chat_quote_contract.py
index 285038e..1b89fc4 100644
--- a/scripts/contracts/test_chat_quote_contract.py
+++ b/scripts/contracts/test_chat_quote_contract.py
@@ -24,10 +24,12 @@ def test_chat_answer_exposes_procedure_quote_and_progress() -> None:
"procedure",
"quote",
"worker_stream",
+ "html",
} <= set(answer["properties"])
assert "source_unnormalized" in contract["data_state"]
assert "stale" in contract["data_state"]
assert hasattr(contracts, "ChatAnswer")
+ assert "html" in contracts.ChatAnswer.__annotations__
assert "stale" in contracts.DATA_STATE_VALUES
diff --git a/scripts/dev/consumer_agent.py b/scripts/dev/consumer_agent.py
index 4241baa..a1ec713 100644
--- a/scripts/dev/consumer_agent.py
+++ b/scripts/dev/consumer_agent.py
@@ -10,6 +10,8 @@
from __future__ import annotations
import argparse
+import binascii
+from base64 import b64decode
from copy import deepcopy
from datetime import UTC, datetime
import hashlib
@@ -28,6 +30,7 @@
MAX_JSON_BYTES = 8 << 20
MAX_PDF_BYTES = 8_000_000
+MAX_HTML_BYTES = 32 << 20
TERMINAL_STATUSES = {"available", "failed", "needs_review", "blocked"}
SENSITIVE_KEYS = {
"authorization", "cookie", "cookies", "headers", "idempotency_key",
@@ -42,6 +45,7 @@
"supplied pdf", "both sources", "article says", "generated by",
"generation source", "provider says",
)
+_HTML_IMAGE_RE = re.compile(rb"]*\bsrc=[\"'](data:image/[^\"']+)[\"']", re.IGNORECASE)
class ConsumerReviewError(RuntimeError):
@@ -101,7 +105,7 @@ def __init__(
self.organization_id = organization_id
self.opener = opener or build_opener().open
- def _request(self, method: str, path: str, *, body: Mapping[str, Any] | None = None, idempotency_key: str = "", accept: str = "application/json") -> Any:
+ def _request(self, method: str, path: str, *, body: Mapping[str, Any] | None = None, idempotency_key: str = "", accept: str = "application/json", max_bytes: int | None = None) -> Any:
if not path.startswith("/") or "?" in path or "#" in path or ".." in path.split("/"):
raise ConsumerReviewError("chat request path is invalid")
headers = {"Accept": accept}
@@ -120,15 +124,23 @@ def _request(self, method: str, path: str, *, body: Mapping[str, Any] | None = N
if idempotency_key:
headers["Idempotency-Key"] = idempotency_key
request = Request(self.base_url + path, data=payload, headers=headers, method=method)
+ response_limit = max_bytes or self.max_bytes
+ if response_limit <= 0:
+ raise ConsumerReviewError("chat response limit must be positive")
try:
with self.opener(request, timeout=self.timeout) as response:
- raw = response.read(self.max_bytes + 1)
- if len(raw) > self.max_bytes:
+ raw = response.read(response_limit + 1)
+ if len(raw) > response_limit:
raise ConsumerReviewError("chat response exceeds size limit")
if accept == "application/pdf":
if not raw.startswith(b"%PDF-"):
raise ConsumerReviewError("chat PDF response is not a PDF")
return raw
+ if accept == "text/html":
+ normalized = raw.lstrip().lower()
+ if not normalized.startswith((b" dic
def pdf(self, query_id: str) -> bytes:
return self._request("GET", f"/chat/queries/{_safe_id(query_id)}/guide.pdf", accept="application/pdf")
+ def html(self, query_id: str) -> bytes:
+ return self._request("GET", f"/chat/queries/{_safe_id(query_id)}/guide.html", accept="text/html", max_bytes=MAX_HTML_BYTES)
+
def _safe_id(value: Any) -> str:
text = str(value or "").strip()
@@ -297,6 +312,12 @@ def consumer_projection(response: Mapping[str, Any]) -> dict[str, Any]:
for key in ("ready", "url", "revision_id")
if key in raw_pdf and raw_pdf[key] is not None
}
+ raw_html = answer.get("html") if isinstance(answer.get("html"), Mapping) else {}
+ projected_answer["html"] = {
+ key: raw_html[key]
+ for key in ("ready", "url", "revision_id")
+ if key in raw_html and raw_html[key] is not None
+ }
options = response.get("vehicle_options") if isinstance(response.get("vehicle_options"), list) else []
return {
"query_id": response.get("query_id"),
@@ -344,7 +365,36 @@ def _procedure_summary_artifacts(steps: Iterable[Any]) -> list[dict[str, Any]]:
return artifacts
-def score_response(case: Mapping[str, Any], response: Mapping[str, Any], *, pdf_response: bytes | None = None) -> dict[str, Any]:
+def _html_image_integrity(html_response: bytes | None, expected_count: int) -> dict[str, Any]:
+ """Validate that the standalone guide embeds exactly its prepared figures."""
+
+ if not html_response:
+ return {"passed": False, "count": 0, "expected": expected_count, "invalid": "missing"}
+ normalized = html_response.lstrip().lower()
+ if not normalized.startswith((b"]+(?:src|href)=[\"']https?://", html_response, re.IGNORECASE) or re.search(rb"]+\bsrc=[\"']https?://", html_response, re.IGNORECASE):
+ return {"passed": False, "count": 0, "expected": expected_count, "invalid": "remote_asset"}
+ matches = _HTML_IMAGE_RE.findall(html_response)
+ invalid = None
+ for uri in matches:
+ try:
+ media, encoded = uri.split(b",", 1)
+ if b";base64" not in media.lower() or not b64decode(encoded, validate=True):
+ invalid = "invalid_data_uri"
+ break
+ except (binascii.Error, ValueError, UnicodeError):
+ invalid = "invalid_data_uri"
+ break
+ return {
+ "passed": invalid is None and len(matches) == expected_count,
+ "count": len(matches),
+ "expected": expected_count,
+ "invalid": invalid,
+ }
+
+
+def score_response(case: Mapping[str, Any], response: Mapping[str, Any], *, pdf_response: bytes | None = None, html_response: bytes | None = None) -> dict[str, Any]:
"""Score one persisted chat answer without using model-generated judgment."""
name = str(case.get("name") or "case")
@@ -435,6 +485,25 @@ def score_response(case: Mapping[str, Any], response: Mapping[str, Any], *, pdf_
findings.append(_finding(case, finding_id=f"{name}:pdf:partial", severity="high", category="contract", path="answer.pdf.ready", message="partial guide incorrectly exposes a final PDF", reproduction=reproduction))
dimensions["pdf_integrity"] = {"passed": pdf_ok, "evidence": {"expected": pdf_expected, "ready": pdf_ready, "revision_match": revision_ok}}
+ html_meta = answer.get("html") if isinstance(answer.get("html"), Mapping) else {}
+ html_ready = html_meta.get("ready") is True
+ html_expected = procedure.get("content_status") == "complete"
+ html_images = _html_image_integrity(html_response, len(figures))
+ html_revision_ok = bool(
+ html_expected
+ and html_ready
+ and html_response
+ and html_response.lstrip().lower().startswith((b"'
+
+
def test_consumer_projection_omits_internal_source_material():
response = {**_complete_response(), "answer": {**_complete_response()["answer"], "source_unnormalized": {"raw_html": "secret"}, "worker_stream": [{"token": "secret"}]}}
projection = consumer_projection(response)
@@ -58,6 +63,7 @@ def test_consumer_projection_omits_internal_source_material():
assert "evidence" not in encoded
assert "worker_stream" not in encoded
assert projection["answer"]["procedure"]["title"] == "Starter Replacement Guide"
+ assert projection["answer"]["html"]["ready"] is True
def test_score_response_passes_complete_illustrated_answer():
@@ -65,6 +71,7 @@ def test_score_response_passes_complete_illustrated_answer():
{"name": "camry-starter", "message": "replace starter", "expected_vehicle": {"vehicle_id": "vehicle-1"}, "expected_components": ["starter"], "min_steps": 2, "min_figures": 2},
_complete_response(),
pdf_response=b"%PDF-1.7 test",
+ html_response=_complete_html(),
)
assert result["decision"] == "pass"
assert result["score"] == 100
@@ -76,6 +83,7 @@ def test_score_response_marks_partial_answer_for_review():
response["answer"]["procedure"]["content_status"] = "partial"
response["answer"]["procedure"]["pdf_ready"] = False
response["answer"]["pdf"] = {"ready": False}
+ response["answer"]["html"] = {"ready": False}
result = score_response(
{"name": "partial", "message": "replace starter", "expected_vehicle": {"vehicle_id": "vehicle-1"}, "expected_components": ["starter"], "min_steps": 2, "min_figures": 2},
response,
@@ -99,6 +107,7 @@ def test_score_response_fails_false_complete_replacement_without_both_phases():
},
response,
pdf_response=b"%PDF-1.7 test",
+ html_response=_complete_html(),
)
assert result["decision"] == "fail"
@@ -122,6 +131,7 @@ def test_score_response_requires_case_declared_procedure_depth_terms():
},
_complete_response(),
pdf_response=b"%PDF-1.7 test",
+ html_response=_complete_html(),
)
assert result["decision"] == "needs_review"
assert result["dimensions"]["procedure_coverage"]["passed"] is False
@@ -152,6 +162,7 @@ def test_score_response_flags_provider_summary_artifact_but_preserves_valid_step
},
response,
pdf_response=b"%PDF-1.7 test",
+ html_response=_complete_html(),
)
assert result["decision"] == "needs_review"
@@ -185,6 +196,9 @@ def get(self, _query_id):
def pdf(self, _query_id):
return b"%PDF-1.7 test"
+ def html(self, _query_id):
+ return _complete_html()
+
class TransientPollingClient(FakeChatClient):
def get(self, query_id):
@@ -212,10 +226,29 @@ def test_run_case_selects_vehicle_and_records_revision_hash():
assert client.selected == [("query-12345678", 1)]
assert result["response_sha256"]
assert result["pdf_sha256"]
+ assert result["html_sha256"]
assert result["expected_decision"] == "pass"
assert result["expectation_met"] is True
+def test_html_integrity_rejects_remote_or_invalid_embedded_figures():
+ response = _complete_response()
+ remote = score_response(
+ {"name": "html-remote", "message": "replace starter", "expected_vehicle": {"vehicle_id": "vehicle-1"}, "expected_components": ["starter"], "min_steps": 2, "min_figures": 2},
+ response,
+ pdf_response=b"%PDF-1.7 test",
+ html_response=b'',
+ )
+ invalid = score_response(
+ {"name": "html-invalid", "message": "replace starter", "expected_vehicle": {"vehicle_id": "vehicle-1"}, "expected_components": ["starter"], "min_steps": 2, "min_figures": 2},
+ response,
+ pdf_response=b"%PDF-1.7 test",
+ html_response=b'',
+ )
+ assert remote["dimensions"]["html_integrity"]["passed"] is False
+ assert invalid["dimensions"]["html_integrity"]["passed"] is False
+
+
def test_http_client_bounds_and_parses_json_without_logging_auth_material():
seen = []
@@ -243,6 +276,25 @@ def opener(request, timeout):
assert seen[0][3] == 20
+def test_http_client_accepts_standalone_html_with_the_larger_artifact_budget():
+ class Response:
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *_args):
+ return False
+
+ def read(self, _limit):
+ return _complete_html()
+
+ client = ChatHTTPClient(
+ "http://127.0.0.1:8080",
+ opener=lambda _request, timeout: Response(),
+ )
+
+ assert client.html("query-12345678") == _complete_html()
+
+
def test_run_cases_records_aggregate_decision_and_does_not_call_github_for_pass():
with TemporaryDirectory() as directory:
report = run_cases(
diff --git a/workers/ingestion-python/src/autodata_ingestion/autoapitwo_guide.py b/workers/ingestion-python/src/autodata_ingestion/autoapitwo_guide.py
index 81fbdf5..1eca371 100644
--- a/workers/ingestion-python/src/autodata_ingestion/autoapitwo_guide.py
+++ b/workers/ingestion-python/src/autodata_ingestion/autoapitwo_guide.py
@@ -70,11 +70,21 @@ def _vehicle_candidate(raw: Mapping[str, Any], index: int) -> dict[str, Any] | N
return None
body_match = re.search(r"\b(2|4)-Door\b", model_text, re.IGNORECASE)
drive_match = re.search(r"\b(2WD|4WD)\b", model_text, re.IGNORECASE)
- make = re.sub(r"\s+Truck$", "", str(raw.get("make") or "").strip(), flags=re.IGNORECASE)
+ raw_make = str(raw.get("make") or "").strip()
+ make = re.sub(r"\s+Truck$", "", raw_make, flags=re.IGNORECASE)
+ prose_make = re.fullmatch(r"For\s+(?:A|An|The)\s+(.+)", make, re.IGNORECASE)
+ if prose_make and prose_make.group(1).strip():
+ make = prose_make.group(1).strip()
year = _number(raw.get("year"))
if year is None or not make:
return None
candidate_key = f"autoapitwo:{provider_id}"
+ label = description or f"{year} {make} {model}"
+ if prose_make and description:
+ provider_prefix = re.compile(
+ rf"^\s*{year}\s+{re.escape(raw_make)}(?=\s|$)", re.IGNORECASE
+ )
+ label = provider_prefix.sub(f"{year} {make}", description, count=1)
return {
"vehicle_id": f"vehicle:{sha256(candidate_key.encode()).hexdigest()[:24]}",
"candidate_key": candidate_key,
@@ -86,7 +96,7 @@ def _vehicle_candidate(raw: Mapping[str, Any], index: int) -> dict[str, Any] | N
"body_style": body_match.group(1) + "-door" if body_match else None,
"drivetrain": drive_match.group(1).upper() if drive_match else None,
"engine_displacement_l": _engine_litres(raw.get("engine")),
- "label": description or f"{year} {make} {model}",
+ "label": label,
"confidence": 1.0,
}
@@ -232,11 +242,15 @@ def _text(value: Any) -> str:
return re.sub(r"\s+", " ", str(value or "")).strip()
+def _is_malformed_provider_label(label: str) -> bool:
+ return bool(re.match(r"^(?:19|20)\d{2}\s+For\s+(?:A|An|The)\s+", label, re.IGNORECASE))
+
+
def _vehicle_applicability(vehicle: Mapping[str, Any]) -> str:
"""Return the most specific consumer-safe identity for the selected vehicle."""
label = _text(vehicle.get("label"))
- if label:
+ if label and not _is_malformed_provider_label(label):
return label
parts = [
diff --git a/workers/ingestion-python/src/autodata_ingestion/chat_service.py b/workers/ingestion-python/src/autodata_ingestion/chat_service.py
index e8ac5f7..1177083 100644
--- a/workers/ingestion-python/src/autodata_ingestion/chat_service.py
+++ b/workers/ingestion-python/src/autodata_ingestion/chat_service.py
@@ -231,6 +231,8 @@ def claim_due(
_dependencies = ChatDependencies()
_runtime_lock = threading.RLock()
_guide_pdf_cache: dict[str, bytes] = {}
+_guide_html_cache: dict[str, bytes] = {}
+_guide_prepared_cache: dict[str, dict[str, Any]] = {}
_guide_pdf_cache_lock = threading.RLock()
@@ -574,6 +576,12 @@ def select_chat_vehicle(
def get_chat_query(query_id: str, *, principal: Mapping[str, Any] | None = None) -> dict[str, Any]:
+ return _public_query(_get_authorized_query(query_id, principal=principal))
+
+
+def _get_authorized_query(query_id: str, *, principal: Mapping[str, Any] | None) -> dict[str, Any]:
+ """Load one query after authorization for artifact rendering."""
+
query_text = _required_text(query_id, "query_id")
with _runtime_lock:
_require_durable_runtime()
@@ -581,7 +589,7 @@ def get_chat_query(query_id: str, *, principal: Mapping[str, Any] | None = None)
if query is None:
raise KeyError(f"chat query {query_text} was not found")
_authorize_query(query, principal)
- return _public_query(query)
+ return redact_secrets(query)
def iter_chat_events(
@@ -1419,6 +1427,17 @@ def _answer_from_result(
"url": f"/chat/queries/{query_id}/guide.pdf",
"revision_id": str(procedure.get("revision_id") or ""),
}
+ if (
+ isinstance(procedure, Mapping)
+ and procedure.get("content_status") == "complete"
+ and procedure.get("pdf_ready") is True
+ and str(procedure.get("revision_id") or "").strip()
+ ):
+ answer["html"] = {
+ "ready": True,
+ "url": f"/chat/queries/{query_id}/guide.html",
+ "revision_id": str(procedure.get("revision_id") or ""),
+ }
source_watermark = safe.get("source_watermark")
if source_watermark is None and isinstance(safe.get("source"), Mapping):
source_watermark = safe["source"].get("source_watermark") or safe["source"].get("source_version")
@@ -1456,16 +1475,13 @@ def _answer_from_result(
def render_chat_guide_pdf(query_id: str, *, principal: Mapping[str, Any]) -> bytes:
"""Render only the authorized, immutable complete guide revision."""
- query = get_chat_query(query_id, principal=principal)
+ query = _get_authorized_query(query_id, principal=principal)
answer = query.get("answer") if isinstance(query, Mapping) else None
guide = answer.get("procedure") if isinstance(answer, Mapping) else None
if not isinstance(guide, Mapping) or guide.get("pdf_ready") is not True:
raise ValueError("a complete guide PDF is not available")
- from .autoapitwo_connector import AutoAPITwoConnector
from .guide_pdf import render_guide_pdf
- vehicle = guide.get("vehicle") if isinstance(guide.get("vehicle"), Mapping) else answer.get("vehicle", {})
- provider_id = str(vehicle.get("autoapitwo_vehicle_id") or "").strip() if isinstance(vehicle, Mapping) else ""
revision_id = str(guide.get("revision_id") or "").strip()
if not revision_id:
raise ValueError("guide revision is missing")
@@ -1473,17 +1489,90 @@ def render_chat_guide_pdf(query_id: str, *, principal: Mapping[str, Any]) -> byt
cached_pdf = _guide_pdf_cache.get(revision_id)
if cached_pdf is not None:
return cached_pdf
+ prepared = _prepare_guide_for_artifact(guide, answer)
+ rendered = render_guide_pdf(prepared)
+ with _guide_pdf_cache_lock:
+ _guide_pdf_cache[revision_id] = rendered
+ while len(_guide_pdf_cache) > 8:
+ _guide_pdf_cache.pop(next(iter(_guide_pdf_cache)))
+ return rendered
+
+
+def render_chat_guide_html(query_id: str, *, principal: Mapping[str, Any]) -> bytes:
+ """Render only the authorized, immutable complete guide revision as HTML."""
+
+ query = _get_authorized_query(query_id, principal=principal)
+ answer = query.get("answer") if isinstance(query, Mapping) else None
+ guide = answer.get("procedure") if isinstance(answer, Mapping) else None
+ if (
+ not isinstance(guide, Mapping)
+ or guide.get("content_status") != "complete"
+ or guide.get("pdf_ready") is not True
+ ):
+ raise ValueError("a complete guide HTML is not available")
+ revision_id = str(guide.get("revision_id") or "").strip()
+ if not revision_id:
+ raise ValueError("guide revision is missing")
+ with _guide_pdf_cache_lock:
+ cached_html = _guide_html_cache.get(revision_id)
+ if cached_html is not None:
+ return cached_html
+ from .guide_html import render_guide_html
+
+ prepared = _prepare_guide_for_artifact(guide, answer)
+ rendered = render_guide_html(prepared)
+ with _guide_pdf_cache_lock:
+ _guide_html_cache[revision_id] = rendered
+ while len(_guide_html_cache) > 8:
+ _guide_html_cache.pop(next(iter(_guide_html_cache)))
+ return rendered
+
+
+def _prepare_guide_for_artifact(
+ guide: Mapping[str, Any], answer: Mapping[str, Any] | None = None
+) -> dict[str, Any]:
+ """Prepare one immutable guide revision's figures for PDF and HTML."""
+
+ revision_id = str(guide.get("revision_id") or "").strip()
+ if not revision_id:
+ raise ValueError("guide revision is missing")
+ with _guide_pdf_cache_lock:
+ cached = _guide_prepared_cache.get(revision_id)
+ if cached is not None:
+ return deepcopy(cached)
+
+ from .autoapitwo_connector import AutoAPITwoConnector
+
prepared = deepcopy(dict(guide))
+ vehicle = guide.get("vehicle") if isinstance(guide.get("vehicle"), Mapping) else {}
+ if not vehicle and isinstance(answer, Mapping) and isinstance(answer.get("vehicle"), Mapping):
+ vehicle = answer["vehicle"]
+ provider_id = str(vehicle.get("autoapitwo_vehicle_id") or "").strip() if isinstance(vehicle, Mapping) else ""
steps = prepared.get("steps", []) if isinstance(prepared.get("steps"), list) else []
image_refs = [
image
for step in steps
- if isinstance(step, dict)
+ if isinstance(step, Mapping)
for image in (step.get("images", []) if isinstance(step.get("images"), list) else [])
- if isinstance(image, dict) and image.get("url")
+ if isinstance(image, Mapping)
]
- expected_images = len(image_refs)
- unique_urls = list(dict.fromkeys(str(image["url"]) for image in image_refs))
+ top_level_images = prepared.get("images", [])
+ if isinstance(top_level_images, Mapping):
+ top_level_images = [top_level_images]
+ if top_level_images is not None and not isinstance(top_level_images, list):
+ raise ValueError("guide figures could not be prepared")
+ if any(not isinstance(image, Mapping) for image in top_level_images or []):
+ raise ValueError("guide figures could not be prepared")
+ image_refs.extend(
+ image for image in top_level_images or [] if isinstance(image, Mapping)
+ )
+ unique_urls = list(
+ dict.fromkeys(
+ str(image["url"])
+ for image in image_refs
+ if image.get("url") and not image.get("image_bytes")
+ )
+ )
connector = AutoAPITwoConnector(
os.getenv("AUTODATA_AUTOAPITWO_BASE_URL", "https://autoapitwo.vercel.app")
)
@@ -1492,27 +1581,25 @@ def fetch_image(url: str) -> tuple[str, bytes]:
return url, connector.read(url, car_id=provider_id or None, binary=True)
image_bytes: dict[str, bytes] = {}
- with ThreadPoolExecutor(max_workers=min(4, max(1, len(unique_urls)))) as pool:
- for url, value in pool.map(fetch_image, unique_urls):
- image_bytes[url] = value
+ if unique_urls:
+ with ThreadPoolExecutor(max_workers=min(4, len(unique_urls))) as pool:
+ for url, value in pool.map(fetch_image, unique_urls):
+ image_bytes[url] = value
for image in image_refs:
- if str(image["url"]) in image_bytes:
+ if not image.get("image_bytes") and str(image.get("url") or "") in image_bytes:
image["image_bytes"] = image_bytes[str(image["url"])]
- all_images = [
- image
- for step in prepared.get("steps", [])
- if isinstance(prepared.get("steps"), list) and isinstance(step, Mapping)
- for image in (step.get("images", []) if isinstance(step.get("images"), list) else [])
- if isinstance(image, Mapping) and image.get("url")
- ]
- if expected_images and not all(isinstance(image, Mapping) and image.get("image_bytes") for image in all_images):
+ if any(
+ not isinstance(image.get("image_bytes"), (bytes, bytearray, memoryview))
+ or not bytes(image["image_bytes"])
+ for image in image_refs
+ ):
raise ValueError("guide figures could not be prepared")
- rendered = render_guide_pdf(prepared)
+
with _guide_pdf_cache_lock:
- _guide_pdf_cache[revision_id] = rendered
- while len(_guide_pdf_cache) > 8:
- _guide_pdf_cache.pop(next(iter(_guide_pdf_cache)))
- return rendered
+ _guide_prepared_cache[revision_id] = deepcopy(prepared)
+ while len(_guide_prepared_cache) > 8:
+ _guide_prepared_cache.pop(next(iter(_guide_prepared_cache)))
+ return prepared
def _apply_answer(query: dict[str, Any], answer: Mapping[str, Any]) -> None:
@@ -2066,5 +2153,7 @@ def _ack_chat_claim(claim: Any) -> None:
"process_chat_jobs",
"process_chat_price_jobs",
"publish_chat_progress",
+ "render_chat_guide_html",
+ "render_chat_guide_pdf",
"select_chat_vehicle",
]
diff --git a/workers/ingestion-python/src/autodata_ingestion/guide_html.py b/workers/ingestion-python/src/autodata_ingestion/guide_html.py
new file mode 100644
index 0000000..f937b27
--- /dev/null
+++ b/workers/ingestion-python/src/autodata_ingestion/guide_html.py
@@ -0,0 +1,337 @@
+"""Deterministic standalone HTML rendering for complete repair guides."""
+
+from __future__ import annotations
+
+from base64 import b64encode
+from html import escape
+import re
+from typing import Any, Mapping
+
+
+_GENERIC_FIGURE_CAPTIONS = frozenset(
+ {
+ "diagram",
+ "figure",
+ "image",
+ "picture",
+ "procedure figure",
+ "source diagram",
+ }
+)
+
+_INLINE_CSS = """
+:root { color-scheme: light; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #27352f; background: #f5f8f6; }
+body { max-width: 48rem; margin: 0 auto; padding: 2rem 1.25rem 3rem; background: #fff; line-height: 1.55; }
+h1, h2, h3 { color: #17231f; line-height: 1.2; }
+h1 { margin: 0 0 .35rem; font-size: 2rem; }
+h2 { margin-top: 2rem; border-bottom: 1px solid #d8e0db; padding-bottom: .35rem; }
+h3 { margin: 0 0 .35rem; font-size: 1.05rem; }
+.subtitle { margin: 0 0 1.25rem; color: #53625c; font-size: 1.05rem; }
+.metadata { display: grid; grid-template-columns: max-content 1fr; gap: .3rem .8rem; margin: 1rem 0 1.5rem; padding: .9rem 1rem; background: #eef4f0; border-radius: .5rem; font-size: .9rem; }
+.metadata dt { font-weight: 700; color: #53625c; }
+.metadata dd { margin: 0; overflow-wrap: anywhere; }
+.warning { margin: .75rem 0; padding: .75rem 1rem; border-left: .3rem solid #e7b24a; background: #fff5df; }
+.steps { padding-left: 1.75rem; }
+.step { padding: .2rem 0 1.2rem .35rem; }
+.instruction { margin: .35rem 0; }
+figure { margin: 1rem 0; padding: .75rem; background: #f5f8f6; border: 1px solid #d8e0db; border-radius: .4rem; }
+img { display: block; max-width: 100%; height: auto; margin: 0 auto; }
+figcaption { margin-top: .55rem; color: #53625c; font-size: .88rem; text-align: center; }
+.evidence { margin: .55rem 0 0; color: #53625c; font-size: .85rem; }
+.evidence ul { margin: .2rem 0 0; }
+footer { margin-top: 2rem; padding-top: .8rem; border-top: 1px solid #d8e0db; color: #53625c; font-size: .82rem; }
+""".strip()
+
+_EXTENSION_MEDIA_TYPES = {
+ ".bmp": "image/bmp",
+ ".gif": "image/gif",
+ ".jpeg": "image/jpeg",
+ ".jpg": "image/jpeg",
+ ".png": "image/png",
+ ".svg": "image/svg+xml",
+ ".tif": "image/tiff",
+ ".tiff": "image/tiff",
+ ".webp": "image/webp",
+}
+
+
+def _text(value: Any) -> str:
+ if isinstance(value, Mapping):
+ value = value.get("message", value.get("text", value.get("description", "")))
+ return str(value or "").strip()
+
+
+def _escaped(value: Any) -> str:
+ return escape(_text(value), quote=False)
+
+
+def _attribute(value: Any) -> str:
+ return escape(_text(value), quote=True)
+
+
+def _caption(image: Mapping[str, Any]) -> str:
+ for key in ("alt", "description", "label", "title"):
+ value = _text(image.get(key))
+ if value and value.casefold() not in _GENERIC_FIGURE_CAPTIONS:
+ return value
+ return ""
+
+
+def _media_type(image: Mapping[str, Any]) -> str:
+ for key in ("media_type", "mime_type", "content_type"):
+ value = _text(image.get(key)).split(";", 1)[0].casefold()
+ if value.startswith("image/") and re.fullmatch(r"image/[a-z0-9.+-]+", value):
+ return value
+
+ url = _text(image.get("url"))
+ extension = re.search(r"(\.[a-z0-9]+)(?:[?#]|$)", url.casefold())
+ if extension and extension.group(1) in _EXTENSION_MEDIA_TYPES:
+ return _EXTENSION_MEDIA_TYPES[extension.group(1)]
+
+ payload = image.get("image_bytes")
+ if isinstance(payload, (bytes, bytearray, memoryview)):
+ raw = bytes(payload)
+ if raw.startswith(b"\x89PNG"):
+ return "image/png"
+ if raw.startswith(b"\xff\xd8\xff"):
+ return "image/jpeg"
+ if raw.startswith((b"GIF87a", b"GIF89a")):
+ return "image/gif"
+ if raw.startswith(b"RIFF") and raw[8:12] == b"WEBP":
+ return "image/webp"
+ if raw.lstrip().startswith(b"