Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions apps/api-go/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
37 changes: 37 additions & 0 deletions apps/api-go/chat_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -224,6 +236,31 @@ func TestChatGuidePDFForwardsQueryIDAndReturnsPrivatePDF(t *testing.T) {
}
}

func TestChatGuideHTMLForwardsQueryIDAndReturnsPrivateHTML(t *testing.T) {
client := &fakeChatClient{guideHTMLStatus: http.StatusOK, guideHTMLReply: []byte("<!doctype html><img src=\"data:image/png;base64,AA==\">")}
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)
Expand Down
5 changes: 5 additions & 0 deletions apps/api-go/dashboard/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
1 change: 1 addition & 0 deletions apps/api-go/dashboard/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ <h3 id="procedure-title">No procedure yet</h3>
<span class="review-badge" id="review-badge" hidden>UNREVIEWED</span>
</div>
<p class="generation-line" id="procedure-generation">A structured procedure will appear here.</p>
<a class="guide-button html-button" id="procedure-html" hidden download="autodata-repair-guide.html" href="#">Download complete guide as HTML <span aria-hidden="true">↗</span></a>
<a class="pdf-button" id="procedure-pdf" hidden download="autodata-repair-guide.pdf" href="#">Download complete guide as PDF <span aria-hidden="true">↗</span></a>
<p class="technical-line technical-only" id="procedure-technical-generation"></p>
<div class="notice-list" id="procedure-warnings" hidden></div>
Expand Down
5 changes: 3 additions & 2 deletions apps/api-go/dashboard/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
Expand Down
4 changes: 4 additions & 0 deletions apps/api-go/dashboard_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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\"",
Expand Down Expand Up @@ -90,6 +92,8 @@ func TestDashboardRouteServesJavaScriptAsset(t *testing.T) {
"required_hours",
"recommended_hours",
"Generated by",
"answer.html",
"procedure-html",
"UNREVIEWED",
"worker_stream",
"renderConsumerSummary",
Expand Down
36 changes: 36 additions & 0 deletions apps/api-go/ingestion_http.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
37 changes: 37 additions & 0 deletions apps/api-go/ingestion_http_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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("<!doctype html><html><body>guide</body></html>"))
}))
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) != "<!doctype html><html><body>guide</body></html>" {
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) {
Expand Down
1 change: 1 addition & 0 deletions apps/api-go/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
23 changes: 20 additions & 3 deletions docs/architecture/consumer-repair-guides.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`.
Loading
Loading