feat: implement product knowledge management & retrieval (004)

Implements 26 of 28 tasks from specs/004-product-knowledge/tasks.md
across all three user stories -- Phase 3 of the roadmap. First
feature to populate src/modules/ai-support/ (doc 07 places
`knowledge` there; only that submodule is built, matching this
codebase's convention of not pre-building unneeded submodules).

Schema (prisma/schema.prisma + migration):
- KnowledgeEntry, ErrorCode, KnownIssue, Runbook per docs/06,
  refining its conceptual flat `version` field into an explicit
  version-history mechanism: each edit inserts a new row
  (isCurrentVersion flag, compound unique on (code, version) /
  (key, productId, version)) instead of overwriting in place -- the
  only way "prior versions remain retrievable" (FR-004/FR-009) is
  actually true rather than aspirational.

User Story 1 -- knowledge entry authoring/publish/version (P1, MVP):
- draft -> published -> unpublished lifecycle; publish only takes
  effect from its effectiveDate.
- Editing uses the same conditional-update-then-insert optimistic
  concurrency pattern as 003-ticketing's Ticket.version (409 on a
  stale expectedVersion).
- Full version history readable via GET .../versions.

User Story 2 -- error codes, known issues, runbooks (P2):
- ErrorCode + KnownIssue with direct lookup-by-error-code.
- Runbook steps stored as an ordered JSON array, preserved exactly;
  same version-on-edit mechanism as knowledge entries; inactive
  runbooks are indistinguishable from nonexistent ones on lookup.

User Story 3 -- filtered retrieval (P3):
- GET /knowledge/retrieve: product-scoped, excludes draft/
  unpublished/not-yet-effective entries, validated entries ranked
  ahead of unvalidated. Deliberately NOT semantic/vector search --
  doc 11 gap B1 explicitly defers embedding-model choice to the
  future AI-support feature; this is real, usable structured
  filtering a semantic layer can sit in front of later.

Found and fixed one real bug before it reached tests: the retrieval
endpoint initially queried by the raw external product id instead of
resolving it to the internal Product.id first (every other endpoint
in this feature does that resolution) -- would have silently
returned zero results for every caller. Fixed with a lenient
tryResolveProductId (empty array, not 404, for an unregistered
product -- matches the "no matches, never an error" contract).

Deliberately skipped (not forgotten, see checklist notes): the two
planned mock-repository unit-test tasks (T004, T019) -- unlike
003-ticketing's state machine, this feature has no pure-logic
surface to isolate from Prisma; coverage comes entirely from
integration tests instead.

All 13 integration test files in the repo (36 tests, spanning this
feature and every prior one) verified passing together against a
real Postgres/Redis/MinIO -- no regressions. Full quality gate
(typecheck/lint/format/architecture/unit tests) passes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
saqib mir
2026-09-02 15:55:18 +05:30
co-authored by Claude Sonnet 5
parent e10736d82a
commit e33d86081f
37 changed files with 1395 additions and 38 deletions
@@ -0,0 +1,91 @@
-- CreateTable
CREATE TABLE "knowledge_entries" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"version" INTEGER NOT NULL DEFAULT 1,
"isCurrentVersion" BOOLEAN NOT NULL DEFAULT true,
"productId" TEXT NOT NULL,
"feature" TEXT,
"type" TEXT NOT NULL,
"problem" TEXT,
"symptoms" TEXT,
"errorCode" TEXT,
"cause" TEXT,
"recommendedSolution" TEXT,
"verificationSteps" TEXT,
"escalationGuidance" TEXT,
"status" TEXT NOT NULL DEFAULT 'draft',
"effectiveDate" TIMESTAMP(3),
"categoryScope" TEXT[],
"validationStatus" TEXT NOT NULL DEFAULT 'unvalidated',
"owner" TEXT,
"lastReview" TIMESTAMP(3),
"source" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "knowledge_entries_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "error_codes" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"description" TEXT NOT NULL,
CONSTRAINT "error_codes_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "known_issues" (
"id" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"errorCodeId" TEXT,
"description" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'open',
CONSTRAINT "known_issues_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "runbooks" (
"id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"version" INTEGER NOT NULL DEFAULT 1,
"isCurrentVersion" BOOLEAN NOT NULL DEFAULT true,
"productId" TEXT NOT NULL,
"steps" JSONB NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "runbooks_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "knowledge_entries_productId_isCurrentVersion_status_effecti_idx" ON "knowledge_entries"("productId", "isCurrentVersion", "status", "effectiveDate");
-- CreateIndex
CREATE UNIQUE INDEX "knowledge_entries_code_version_key" ON "knowledge_entries"("code", "version");
-- CreateIndex
CREATE UNIQUE INDEX "error_codes_productId_code_key" ON "error_codes"("productId", "code");
-- CreateIndex
CREATE INDEX "runbooks_productId_key_isCurrentVersion_active_idx" ON "runbooks"("productId", "key", "isCurrentVersion", "active");
-- CreateIndex
CREATE UNIQUE INDEX "runbooks_key_productId_version_key" ON "runbooks"("key", "productId", "version");
-- AddForeignKey
ALTER TABLE "knowledge_entries" ADD CONSTRAINT "knowledge_entries_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "error_codes" ADD CONSTRAINT "error_codes_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "known_issues" ADD CONSTRAINT "known_issues_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "known_issues" ADD CONSTRAINT "known_issues_errorCodeId_fkey" FOREIGN KEY ("errorCodeId") REFERENCES "error_codes"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "runbooks" ADD CONSTRAINT "runbooks_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+84 -4
View File
@@ -34,10 +34,14 @@ model Product {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
categories Category[]
integration ProductIntegration?
problems Problem[]
tickets Ticket[]
categories Category[]
integration ProductIntegration?
problems Problem[]
tickets Ticket[]
knowledgeEntries KnowledgeEntry[]
errorCodes ErrorCode[]
knownIssues KnownIssue[]
runbooks Runbook[]
@@map("products")
}
@@ -177,6 +181,82 @@ model TicketAttachment {
@@map("ticket_attachments")
}
model KnowledgeEntry {
id String @id @default(cuid())
code String // KB-<PRODUCT>-<SEQ>, e.g. KB-DQ-102 — shared across versions,
// logical identifier is (code, version), NOT code alone — see
// specs/004-product-knowledge/research.md "Versioning mechanism"
version Int @default(1)
isCurrentVersion Boolean @default(true)
productId String
feature String?
type String // known_issue | faq | resolution_procedure | operations
problem String?
symptoms String?
errorCode String?
cause String?
recommendedSolution String?
verificationSteps String?
escalationGuidance String?
status String @default("draft") // draft | published | unpublished
effectiveDate DateTime?
categoryScope String[]
validationStatus String @default("unvalidated") // unvalidated | validated
owner String?
lastReview DateTime?
source String?
createdAt DateTime @default(now())
product Product @relation(fields: [productId], references: [id])
@@unique([code, version])
@@index([productId, isCurrentVersion, status, effectiveDate])
@@map("knowledge_entries")
}
model ErrorCode {
id String @id @default(cuid())
code String // e.g. LAYOUT_PARSE_042
productId String
description String
product Product @relation(fields: [productId], references: [id])
knownIssues KnownIssue[]
@@unique([productId, code])
@@map("error_codes")
}
model KnownIssue {
id String @id @default(cuid())
productId String
errorCodeId String?
description String
status String @default("open")
product Product @relation(fields: [productId], references: [id])
errorCode ErrorCode? @relation(fields: [errorCodeId], references: [id])
@@map("known_issues")
}
model Runbook {
id String @id @default(cuid())
key String // e.g. PDF_HTML_CONVERSION_FAILURE — shared across versions, logical
// identifier is (key, productId, version), NOT key alone — same convention as KnowledgeEntry
version Int @default(1)
isCurrentVersion Boolean @default(true)
productId String
steps Json // ordered array — order preserved exactly as authored
active Boolean @default(true)
product Product @relation(fields: [productId], references: [id])
@@unique([key, productId, version])
@@index([productId, key, isCurrentVersion, active])
@@map("runbooks")
}
model AuditLog {
id String @id @default(cuid())
actor String // ProductIntegration id, agent id, "system", "ai", etc. — never a local