feat: add ontology material ingestion
This commit is contained in:
@@ -49,12 +49,12 @@ interview-ready after each short practice loop.
|
|||||||
|
|
||||||
### Ontology and Learning Materials
|
### Ontology and Learning Materials
|
||||||
|
|
||||||
- [ ] **ONTO-01**: User or operator can upload learning materials.
|
- [x] **ONTO-01**: User or operator can upload learning materials.
|
||||||
- [ ] **ONTO-02**: System creates source-backed ontology candidate nodes and
|
- [x] **ONTO-02**: System creates source-backed ontology candidate nodes and
|
||||||
edges.
|
edges.
|
||||||
- [ ] **ONTO-03**: System detects missing prerequisites and weakly supported
|
- [x] **ONTO-03**: System detects missing prerequisites and weakly supported
|
||||||
concepts.
|
concepts.
|
||||||
- [ ] **ONTO-04**: Generated or inferred content is marked as candidate until
|
- [x] **ONTO-04**: Generated or inferred content is marked as candidate until
|
||||||
reviewed.
|
reviewed.
|
||||||
|
|
||||||
### Teaching Assets
|
### Teaching Assets
|
||||||
@@ -98,7 +98,7 @@ interview-ready after each short practice loop.
|
|||||||
| INT-01..INT-06 | Phase 2 | Complete |
|
| INT-01..INT-06 | Phase 2 | Complete |
|
||||||
| MEM-01..MEM-05 | Phase 3 | Complete |
|
| MEM-01..MEM-05 | Phase 3 | Complete |
|
||||||
| PROG-01..PROG-05 | Phase 4 | Complete |
|
| PROG-01..PROG-05 | Phase 4 | Complete |
|
||||||
| ONTO-01..ONTO-04 | Phase 5 | Pending |
|
| ONTO-01..ONTO-04 | Phase 5 | Complete |
|
||||||
| ASSET-01..ASSET-03 | Phase 6 | Pending |
|
| ASSET-01..ASSET-03 | Phase 6 | Pending |
|
||||||
|
|
||||||
**Coverage:**
|
**Coverage:**
|
||||||
@@ -108,4 +108,4 @@ interview-ready after each short practice loop.
|
|||||||
|
|
||||||
---
|
---
|
||||||
*Requirements defined: 2026-04-26*
|
*Requirements defined: 2026-04-26*
|
||||||
*Last updated: 2026-04-26 after Phase 4 execution.*
|
*Last updated: 2026-04-26 after Phase 5 execution.*
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ See: `.planning/PROJECT.md` (updated 2026-04-26)
|
|||||||
**Core value:** The user should feel and prove that they are becoming more
|
**Core value:** The user should feel and prove that they are becoming more
|
||||||
interview-ready after each short practice loop.
|
interview-ready after each short practice loop.
|
||||||
|
|
||||||
**Current focus:** Phase 5 planning: Ontology and Learning Materials.
|
**Current focus:** Phase 6 planning: Teaching Assets.
|
||||||
|
|
||||||
## Current Decisions
|
## Current Decisions
|
||||||
|
|
||||||
@@ -29,14 +29,16 @@ interview-ready after each short practice loop.
|
|||||||
schedules.
|
schedules.
|
||||||
- Phase 4 progression is implemented and verified with readiness map and next
|
- Phase 4 progression is implemented and verified with readiness map and next
|
||||||
challenge APIs derived from learner memory evidence.
|
challenge APIs derived from learner memory evidence.
|
||||||
|
- Phase 5 ontology material ingestion is implemented and verified with
|
||||||
|
source-backed candidate concepts, prerequisite edges, and candidate gaps.
|
||||||
|
|
||||||
## Next Actions
|
## Next Actions
|
||||||
|
|
||||||
1. Plan Phase 5 ontology and learning material ingestion with GSD.
|
1. Plan Phase 6 teaching asset prompt generation with GSD.
|
||||||
2. Keep `docs/planning/WORKFLOW_CONTRACTS.md` aligned with Go structs during
|
2. Keep `docs/planning/WORKFLOW_CONTRACTS.md` aligned with Go structs during
|
||||||
future workflow implementation.
|
future workflow implementation.
|
||||||
3. Decide the MVP ontology storage boundary before accepting uploaded source
|
3. Verify the production OpenAI image model identifier before real asset
|
||||||
materials.
|
generation calls.
|
||||||
|
|
||||||
## Validation Log
|
## Validation Log
|
||||||
|
|
||||||
@@ -56,6 +58,9 @@ interview-ready after each short practice loop.
|
|||||||
- 2026-04-26: Phase 4 implementation verified with `go test ./...`,
|
- 2026-04-26: Phase 4 implementation verified with `go test ./...`,
|
||||||
`openspec validate bootstrap-job-tutor-platform --strict`, live readiness and
|
`openspec validate bootstrap-job-tutor-platform --strict`, live readiness and
|
||||||
next-challenge smoke, and Go source line-count check.
|
next-challenge smoke, and Go source line-count check.
|
||||||
|
- 2026-04-26: Phase 5 implementation verified with `go test ./...`,
|
||||||
|
`openspec validate bootstrap-job-tutor-platform --strict`, live material
|
||||||
|
ingestion and ontology snapshot smoke, and Go source line-count check.
|
||||||
|
|
||||||
---
|
---
|
||||||
*State initialized: 2026-04-26.*
|
*State initialized: 2026-04-26.*
|
||||||
|
|||||||
37
.planning/phases/005-ontology-materials/005-CONTEXT.md
Normal file
37
.planning/phases/005-ontology-materials/005-CONTEXT.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Phase 5 Context: Ontology and Learning Materials
|
||||||
|
|
||||||
|
**Status:** Ready for execution
|
||||||
|
**Started:** 2026-04-26
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Accept learning material input and produce source-backed ontology candidates.
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
- OpenSpec `learning-ontology` requirements.
|
||||||
|
- Existing workflow contracts for `OntologyGap`.
|
||||||
|
- Backend Developer Interview seed concepts.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
- Use an in-memory ontology store for MVP proof.
|
||||||
|
- Accept JSON material ingestion before multipart file upload.
|
||||||
|
- Mark all generated nodes, edges, and gaps as `candidate`.
|
||||||
|
- Preserve source evidence for every supported ontology candidate.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
In scope:
|
||||||
|
|
||||||
|
- Material ingestion API.
|
||||||
|
- Source-backed ontology candidate nodes and edges.
|
||||||
|
- Gap detection for missing prerequisites and weak evidence.
|
||||||
|
- Ontology snapshot API.
|
||||||
|
|
||||||
|
Out of scope:
|
||||||
|
|
||||||
|
- File storage.
|
||||||
|
- PDF/PPT parsing.
|
||||||
|
- Human review UI.
|
||||||
|
- Canonical promotion workflow.
|
||||||
42
.planning/phases/005-ontology-materials/005-PLAN.md
Normal file
42
.planning/phases/005-ontology-materials/005-PLAN.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Phase 5 Plan: Ontology and Learning Materials
|
||||||
|
|
||||||
|
**Status:** Ready for execution
|
||||||
|
**Phase Goal:** Ingest learning materials into source-backed ontology candidates.
|
||||||
|
|
||||||
|
## Requirements Covered
|
||||||
|
|
||||||
|
- ONTO-01: User or operator can upload learning materials.
|
||||||
|
- ONTO-02: System creates source-backed ontology candidate nodes and edges.
|
||||||
|
- ONTO-03: System detects missing prerequisites and weakly supported concepts.
|
||||||
|
- ONTO-04: Generated or inferred content is marked as candidate until reviewed.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
### 1. Add ontology package
|
||||||
|
|
||||||
|
- Define material, concept candidate, edge candidate, gap, and snapshot types.
|
||||||
|
- Add in-memory store and service.
|
||||||
|
|
||||||
|
### 2. Implement deterministic MVP analyzer
|
||||||
|
|
||||||
|
- Extract known backend interview concept candidates from material text.
|
||||||
|
- Create prerequisite edges for supported concept pairs.
|
||||||
|
- Create gap candidates for missing prerequisites and weak evidence.
|
||||||
|
|
||||||
|
### 3. Add HTTP endpoints
|
||||||
|
|
||||||
|
- `POST /api/v1/materials`
|
||||||
|
- `GET /api/v1/ontology`
|
||||||
|
|
||||||
|
### 4. Add tests and verification
|
||||||
|
|
||||||
|
- Test material ingestion creates source-backed candidates.
|
||||||
|
- Test gaps are candidate-only.
|
||||||
|
- Test HTTP ingestion and ontology snapshot flow.
|
||||||
|
- Run Go tests, OpenSpec validation, line-count check, and smoke.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Multipart upload.
|
||||||
|
- Real document parsers.
|
||||||
|
- Human review promotion.
|
||||||
28
.planning/phases/005-ontology-materials/005-RESEARCH.md
Normal file
28
.planning/phases/005-ontology-materials/005-RESEARCH.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Phase 5 Research: Ontology and Learning Materials
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
The first useful ontology proof does not need heavy parsing. It needs a clean
|
||||||
|
boundary that proves uploaded material can become inspectable candidate
|
||||||
|
knowledge with provenance.
|
||||||
|
|
||||||
|
The MVP should:
|
||||||
|
|
||||||
|
- store material metadata and source text
|
||||||
|
- extract concept candidates from known backend interview concepts
|
||||||
|
- create prerequisite edges from a small deterministic rule set
|
||||||
|
- identify weak concepts when source support is thin
|
||||||
|
- never mark generated or inferred content as canonical
|
||||||
|
|
||||||
|
## Recommended Shape
|
||||||
|
|
||||||
|
- `internal/ontology` owns material ingestion, candidate storage, and snapshot.
|
||||||
|
- HTTP exposes JSON ingestion first.
|
||||||
|
- Evidence references use the existing workflow shared type.
|
||||||
|
- Gap records distinguish source-backed weakness from generated inference.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- Overbuilding parsers too early would violate YAGNI.
|
||||||
|
- Treating keyword extraction as canonical knowledge would violate OpenSpec.
|
||||||
|
- A future parser can replace the analyzer behind the same service boundary.
|
||||||
36
.planning/phases/005-ontology-materials/005-SUMMARY.md
Normal file
36
.planning/phases/005-ontology-materials/005-SUMMARY.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Phase 5 Summary
|
||||||
|
|
||||||
|
**Status:** Complete
|
||||||
|
**Completed:** 2026-04-26
|
||||||
|
|
||||||
|
## Delivered
|
||||||
|
|
||||||
|
- Added `internal/ontology` for materials, concept candidates, edge candidates,
|
||||||
|
gaps, and snapshots.
|
||||||
|
- Added deterministic MVP analyzer for known backend interview concepts.
|
||||||
|
- Added source evidence to every supported concept and edge candidate.
|
||||||
|
- Added candidate-only gap records for missing prerequisites and weak evidence.
|
||||||
|
- Added HTTP endpoints:
|
||||||
|
- `POST /api/v1/materials`
|
||||||
|
- `GET /api/v1/ontology`
|
||||||
|
- Added ontology unit tests and HTTP flow tests.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
gofmt -w cmd internal
|
||||||
|
go test ./...
|
||||||
|
openspec validate bootstrap-job-tutor-platform --strict
|
||||||
|
```
|
||||||
|
|
||||||
|
Additional smoke check:
|
||||||
|
|
||||||
|
- Material ingestion followed by ontology snapshot returned candidate concepts,
|
||||||
|
edges, and gaps.
|
||||||
|
|
||||||
|
## Deferred
|
||||||
|
|
||||||
|
- Multipart uploads.
|
||||||
|
- PPT/PDF/document parsing.
|
||||||
|
- Human review and canonical promotion.
|
||||||
|
- Graph database persistence.
|
||||||
29
.planning/phases/005-ontology-materials/005-VERIFICATION.md
Normal file
29
.planning/phases/005-ontology-materials/005-VERIFICATION.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Phase 5 Verification
|
||||||
|
|
||||||
|
## Verdict
|
||||||
|
|
||||||
|
PASS
|
||||||
|
|
||||||
|
## Requirement Coverage
|
||||||
|
|
||||||
|
- ONTO-01: PASS. JSON material ingestion API accepts operator-provided learning
|
||||||
|
material.
|
||||||
|
- ONTO-02: PASS. Ingestion creates source-backed candidate concepts and
|
||||||
|
prerequisite edges.
|
||||||
|
- ONTO-03: PASS. The analyzer creates candidate gaps for missing prerequisites
|
||||||
|
and weak source evidence.
|
||||||
|
- ONTO-04: PASS. All generated ontology candidates and gaps use `candidate`
|
||||||
|
review state.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
- `go test ./...` passed.
|
||||||
|
- `openspec validate bootstrap-job-tutor-platform --strict` passed.
|
||||||
|
- Live material ingestion and ontology snapshot smoke passed.
|
||||||
|
- Go source line-count check passed.
|
||||||
|
|
||||||
|
## Residual Risk
|
||||||
|
|
||||||
|
The analyzer is deterministic and intentionally shallow. It proves the product
|
||||||
|
boundary but should later be replaced or supplemented with parser-backed and
|
||||||
|
LLM-assisted extraction.
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"tutor/internal/httpapi"
|
"tutor/internal/httpapi"
|
||||||
"tutor/internal/interview"
|
"tutor/internal/interview"
|
||||||
"tutor/internal/learnermemory"
|
"tutor/internal/learnermemory"
|
||||||
|
"tutor/internal/ontology"
|
||||||
"tutor/internal/progression"
|
"tutor/internal/progression"
|
||||||
"tutor/internal/workflows"
|
"tutor/internal/workflows"
|
||||||
)
|
)
|
||||||
@@ -16,8 +17,9 @@ func NewServer(cfg config.Config) *http.Server {
|
|||||||
store := interview.NewMemoryStore()
|
store := interview.NewMemoryStore()
|
||||||
memory := learnermemory.NewService(learnermemory.NewMemoryStore())
|
memory := learnermemory.NewService(learnermemory.NewMemoryStore())
|
||||||
progress := progression.NewService(memory)
|
progress := progression.NewService(memory)
|
||||||
|
onto := ontology.NewService(ontology.NewMemoryStore())
|
||||||
service := interview.NewService(store, runner, memory)
|
service := interview.NewService(store, runner, memory)
|
||||||
handler := httpapi.NewHandler(cfg, service, memory, progress)
|
handler := httpapi.NewHandler(cfg, service, memory, progress, onto)
|
||||||
|
|
||||||
return &http.Server{
|
return &http.Server{
|
||||||
Addr: cfg.HTTPAddr,
|
Addr: cfg.HTTPAddr,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"tutor/internal/config"
|
"tutor/internal/config"
|
||||||
"tutor/internal/interview"
|
"tutor/internal/interview"
|
||||||
"tutor/internal/learnermemory"
|
"tutor/internal/learnermemory"
|
||||||
|
"tutor/internal/ontology"
|
||||||
"tutor/internal/progression"
|
"tutor/internal/progression"
|
||||||
"tutor/internal/workflows"
|
"tutor/internal/workflows"
|
||||||
)
|
)
|
||||||
@@ -18,7 +19,8 @@ func TestDiagnosticHTTPFlow(t *testing.T) {
|
|||||||
memory := learnermemory.NewService(learnermemory.NewMemoryStore())
|
memory := learnermemory.NewService(learnermemory.NewMemoryStore())
|
||||||
service := interview.NewService(interview.NewMemoryStore(), workflows.NewStubRunner(), memory)
|
service := interview.NewService(interview.NewMemoryStore(), workflows.NewStubRunner(), memory)
|
||||||
progress := progression.NewService(memory)
|
progress := progression.NewService(memory)
|
||||||
handler := NewHandler(config.Config{Environment: "test", ModelKey: "deepseek-v4-flash"}, service, memory, progress)
|
onto := ontology.NewService(ontology.NewMemoryStore())
|
||||||
|
handler := NewHandler(config.Config{Environment: "test", ModelKey: "deepseek-v4-flash"}, service, memory, progress, onto)
|
||||||
routes := handler.Routes()
|
routes := handler.Routes()
|
||||||
|
|
||||||
createBody := bytes.NewBufferString(`{
|
createBody := bytes.NewBufferString(`{
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"tutor/internal/config"
|
"tutor/internal/config"
|
||||||
"tutor/internal/interview"
|
"tutor/internal/interview"
|
||||||
"tutor/internal/learnermemory"
|
"tutor/internal/learnermemory"
|
||||||
|
"tutor/internal/ontology"
|
||||||
"tutor/internal/progression"
|
"tutor/internal/progression"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ type Handler struct {
|
|||||||
diagnostic *interview.Service
|
diagnostic *interview.Service
|
||||||
memory *learnermemory.Service
|
memory *learnermemory.Service
|
||||||
progress *progression.Service
|
progress *progression.Service
|
||||||
|
ontology *ontology.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(
|
func NewHandler(
|
||||||
@@ -22,12 +24,14 @@ func NewHandler(
|
|||||||
diagnostic *interview.Service,
|
diagnostic *interview.Service,
|
||||||
memory *learnermemory.Service,
|
memory *learnermemory.Service,
|
||||||
progress *progression.Service,
|
progress *progression.Service,
|
||||||
|
ontology *ontology.Service,
|
||||||
) Handler {
|
) Handler {
|
||||||
return Handler{
|
return Handler{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
diagnostic: diagnostic,
|
diagnostic: diagnostic,
|
||||||
memory: memory,
|
memory: memory,
|
||||||
progress: progress,
|
progress: progress,
|
||||||
|
ontology: ontology,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,6 +44,8 @@ func (h Handler) Routes() http.Handler {
|
|||||||
mux.HandleFunc("GET /api/v1/learners/{userID}/memory", h.getLearnerMemory)
|
mux.HandleFunc("GET /api/v1/learners/{userID}/memory", h.getLearnerMemory)
|
||||||
mux.HandleFunc("GET /api/v1/learners/{userID}/readiness-map", h.getReadinessMap)
|
mux.HandleFunc("GET /api/v1/learners/{userID}/readiness-map", h.getReadinessMap)
|
||||||
mux.HandleFunc("GET /api/v1/learners/{userID}/next-challenge", h.getNextChallenge)
|
mux.HandleFunc("GET /api/v1/learners/{userID}/next-challenge", h.getNextChallenge)
|
||||||
|
mux.HandleFunc("POST /api/v1/materials", h.ingestMaterial)
|
||||||
|
mux.HandleFunc("GET /api/v1/ontology", h.getOntology)
|
||||||
return mux
|
return mux
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"tutor/internal/config"
|
"tutor/internal/config"
|
||||||
"tutor/internal/interview"
|
"tutor/internal/interview"
|
||||||
"tutor/internal/learnermemory"
|
"tutor/internal/learnermemory"
|
||||||
|
"tutor/internal/ontology"
|
||||||
"tutor/internal/progression"
|
"tutor/internal/progression"
|
||||||
"tutor/internal/workflows"
|
"tutor/internal/workflows"
|
||||||
)
|
)
|
||||||
@@ -21,7 +22,8 @@ func TestHealth(t *testing.T) {
|
|||||||
memory := learnermemory.NewService(learnermemory.NewMemoryStore())
|
memory := learnermemory.NewService(learnermemory.NewMemoryStore())
|
||||||
service := interview.NewService(interview.NewMemoryStore(), workflows.NewStubRunner(), memory)
|
service := interview.NewService(interview.NewMemoryStore(), workflows.NewStubRunner(), memory)
|
||||||
progress := progression.NewService(memory)
|
progress := progression.NewService(memory)
|
||||||
handler := NewHandler(cfg, service, memory, progress)
|
onto := ontology.NewService(ontology.NewMemoryStore())
|
||||||
|
handler := NewHandler(cfg, service, memory, progress, onto)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
|
|||||||
48
internal/httpapi/ontology.go
Normal file
48
internal/httpapi/ontology.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"tutor/internal/ontology"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ingestMaterialRequest struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
SourceType string `json:"source_type"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h Handler) ingestMaterial(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.ontology == nil {
|
||||||
|
writeError(w, http.StatusNotFound, "ontology not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req ingestMaterialRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.ontology.Ingest(ontology.IngestInput{
|
||||||
|
Title: req.Title,
|
||||||
|
SourceType: req.SourceType,
|
||||||
|
Body: req.Body,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h Handler) getOntology(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
if h.ontology == nil {
|
||||||
|
writeError(w, http.StatusNotFound, "ontology not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, h.ontology.Snapshot())
|
||||||
|
}
|
||||||
54
internal/httpapi/ontology_test.go
Normal file
54
internal/httpapi/ontology_test.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"tutor/internal/config"
|
||||||
|
"tutor/internal/interview"
|
||||||
|
"tutor/internal/learnermemory"
|
||||||
|
"tutor/internal/ontology"
|
||||||
|
"tutor/internal/progression"
|
||||||
|
"tutor/internal/workflows"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOntologyHTTPFlow(t *testing.T) {
|
||||||
|
memory := learnermemory.NewService(learnermemory.NewMemoryStore())
|
||||||
|
service := interview.NewService(interview.NewMemoryStore(), workflows.NewStubRunner(), memory)
|
||||||
|
progress := progression.NewService(memory)
|
||||||
|
onto := ontology.NewService(ontology.NewMemoryStore())
|
||||||
|
handler := NewHandler(config.Config{Environment: "test"}, service, memory, progress, onto)
|
||||||
|
routes := handler.Routes()
|
||||||
|
|
||||||
|
body := bytes.NewBufferString(`{
|
||||||
|
"title":"Backend interview notes",
|
||||||
|
"source_type":"markdown",
|
||||||
|
"body":"Idempotent API retries need transactions. Cache invalidation uses TTL tradeoffs."
|
||||||
|
}`)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/materials", body)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
routes.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("ingest status = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var result ontology.IngestResult
|
||||||
|
if err := json.NewDecoder(rec.Body).Decode(&result); err != nil {
|
||||||
|
t.Fatalf("decode ingest response: %v", err)
|
||||||
|
}
|
||||||
|
if len(result.Snapshot.Concepts) == 0 {
|
||||||
|
t.Fatal("expected ontology concepts")
|
||||||
|
}
|
||||||
|
|
||||||
|
getReq := httptest.NewRequest(http.MethodGet, "/api/v1/ontology", nil)
|
||||||
|
getRec := httptest.NewRecorder()
|
||||||
|
routes.ServeHTTP(getRec, getReq)
|
||||||
|
|
||||||
|
if getRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("ontology status = %d, body = %s", getRec.Code, getRec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
38
internal/ontology/catalog.go
Normal file
38
internal/ontology/catalog.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package ontology
|
||||||
|
|
||||||
|
import "tutor/internal/workflows"
|
||||||
|
|
||||||
|
var knownConcepts = []knownConcept{
|
||||||
|
{
|
||||||
|
Ref: workflows.ConceptRef{ID: "http-idempotency", Label: "HTTP idempotency", Track: "backend-developer"},
|
||||||
|
Keywords: []string{"idempotent", "idempotency", "retry", "retries"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Ref: workflows.ConceptRef{ID: "database-indexes", Label: "Database indexes", Track: "backend-developer"},
|
||||||
|
Keywords: []string{"index", "indexes", "database index", "query plan"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Ref: workflows.ConceptRef{ID: "cache-invalidation", Label: "Cache invalidation", Track: "backend-developer"},
|
||||||
|
Keywords: []string{"cache", "invalidation", "ttl"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Ref: workflows.ConceptRef{ID: "transactions", Label: "Transactions", Track: "backend-developer"},
|
||||||
|
Keywords: []string{"transaction", "transactions", "atomic", "rollback"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var prerequisiteRules = []prerequisiteRule{
|
||||||
|
{FromID: "http-idempotency", ToID: "transactions"},
|
||||||
|
{FromID: "transactions", ToID: "cache-invalidation"},
|
||||||
|
{FromID: "database-indexes", ToID: "cache-invalidation"},
|
||||||
|
}
|
||||||
|
|
||||||
|
type knownConcept struct {
|
||||||
|
Ref workflows.ConceptRef
|
||||||
|
Keywords []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type prerequisiteRule struct {
|
||||||
|
FromID string
|
||||||
|
ToID string
|
||||||
|
}
|
||||||
192
internal/ontology/service.go
Normal file
192
internal/ontology/service.go
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
package ontology
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"tutor/internal/workflows"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
store Store
|
||||||
|
ids atomic.Uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(store Store) *Service {
|
||||||
|
return &Service{store: store}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Ingest(input IngestInput) (IngestResult, error) {
|
||||||
|
if strings.TrimSpace(input.Title) == "" {
|
||||||
|
return IngestResult{}, errors.New("title is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(input.Body) == "" {
|
||||||
|
return IngestResult{}, errors.New("body is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
material := Material{
|
||||||
|
ID: s.nextID("material"),
|
||||||
|
Title: input.Title,
|
||||||
|
SourceType: sourceTypeOrDefault(input.SourceType),
|
||||||
|
Body: input.Body,
|
||||||
|
CreatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
concepts := s.extractConcepts(material, now)
|
||||||
|
edges := s.extractEdges(concepts, now)
|
||||||
|
gaps := s.detectGaps(concepts, edges, now)
|
||||||
|
if err := s.store.Save(material, concepts, edges, gaps); err != nil {
|
||||||
|
return IngestResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return IngestResult{
|
||||||
|
Material: material,
|
||||||
|
Snapshot: s.store.Snapshot(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Snapshot() Snapshot {
|
||||||
|
return s.store.Snapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) extractConcepts(material Material, now time.Time) []ConceptCandidate {
|
||||||
|
body := strings.ToLower(material.Body)
|
||||||
|
concepts := []ConceptCandidate{}
|
||||||
|
for _, known := range knownConcepts {
|
||||||
|
quote, ok := firstKeywordQuote(body, material.Body, known.Keywords)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
concepts = append(concepts, ConceptCandidate{
|
||||||
|
ID: s.nextID("concept"),
|
||||||
|
Concept: known.Ref,
|
||||||
|
Summary: "Source material mentions " + known.Ref.Label + ".",
|
||||||
|
Evidence: []workflows.EvidenceRef{{
|
||||||
|
Kind: workflows.EvidenceSource,
|
||||||
|
ID: material.ID,
|
||||||
|
Quote: quote,
|
||||||
|
Confidence: 0.72,
|
||||||
|
}},
|
||||||
|
ReviewState: ReviewCandidate,
|
||||||
|
CreatedAt: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.Slice(concepts, func(i, j int) bool {
|
||||||
|
return concepts[i].Concept.ID < concepts[j].Concept.ID
|
||||||
|
})
|
||||||
|
return concepts
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) extractEdges(concepts []ConceptCandidate, now time.Time) []EdgeCandidate {
|
||||||
|
byID := make(map[string]ConceptCandidate, len(concepts))
|
||||||
|
for _, concept := range concepts {
|
||||||
|
byID[concept.Concept.ID] = concept
|
||||||
|
}
|
||||||
|
|
||||||
|
edges := []EdgeCandidate{}
|
||||||
|
for _, rule := range prerequisiteRules {
|
||||||
|
from, fromOK := byID[rule.FromID]
|
||||||
|
to, toOK := byID[rule.ToID]
|
||||||
|
if !fromOK || !toOK {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
edges = append(edges, EdgeCandidate{
|
||||||
|
ID: s.nextID("edge"),
|
||||||
|
From: from.Concept,
|
||||||
|
To: to.Concept,
|
||||||
|
Kind: EdgePrerequisite,
|
||||||
|
Evidence: append([]workflows.EvidenceRef(nil), from.Evidence...),
|
||||||
|
ReviewState: ReviewCandidate,
|
||||||
|
CreatedAt: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return edges
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) detectGaps(
|
||||||
|
concepts []ConceptCandidate,
|
||||||
|
edges []EdgeCandidate,
|
||||||
|
now time.Time,
|
||||||
|
) []Gap {
|
||||||
|
gaps := []Gap{}
|
||||||
|
byID := make(map[string]ConceptCandidate, len(concepts))
|
||||||
|
for _, concept := range concepts {
|
||||||
|
byID[concept.Concept.ID] = concept
|
||||||
|
if len(concept.Evidence) == 1 && len(strings.Fields(concept.Evidence[0].Quote)) < 6 {
|
||||||
|
gaps = append(gaps, Gap{
|
||||||
|
ID: s.nextID("gap"),
|
||||||
|
Concept: concept.Concept,
|
||||||
|
GapType: GapWeakEvidence,
|
||||||
|
Reason: "Concept is mentioned, but source support is thin.",
|
||||||
|
SupportingEvidence: append([]workflows.EvidenceRef(nil), concept.Evidence...),
|
||||||
|
ProposedAction: ActionRequestSource,
|
||||||
|
ReviewState: ReviewCandidate,
|
||||||
|
CreatedAt: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, rule := range prerequisiteRules {
|
||||||
|
to, toOK := byID[rule.ToID]
|
||||||
|
if !toOK {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, fromOK := byID[rule.FromID]; fromOK {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
gaps = append(gaps, Gap{
|
||||||
|
ID: s.nextID("gap"),
|
||||||
|
Concept: to.Concept,
|
||||||
|
GapType: GapMissingPrerequisite,
|
||||||
|
Reason: "Prerequisite concept " + rule.FromID + " is missing from the material.",
|
||||||
|
SupportingEvidence: append([]workflows.EvidenceRef(nil), to.Evidence...),
|
||||||
|
ProposedAction: ActionGenerateCandidate,
|
||||||
|
ReviewState: ReviewCandidate,
|
||||||
|
CreatedAt: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(edges) == 0 && len(concepts) > 1 {
|
||||||
|
first := concepts[0]
|
||||||
|
gaps = append(gaps, Gap{
|
||||||
|
ID: s.nextID("gap"),
|
||||||
|
Concept: first.Concept,
|
||||||
|
GapType: GapMissingPrerequisite,
|
||||||
|
Reason: "Concept relationship is inferred as incomplete and needs review.",
|
||||||
|
SupportingEvidence: append([]workflows.EvidenceRef(nil), first.Evidence...),
|
||||||
|
ProposedAction: ActionHumanReview,
|
||||||
|
ReviewState: ReviewCandidate,
|
||||||
|
CreatedAt: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return gaps
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstKeywordQuote(lowerBody string, originalBody string, keywords []string) (string, bool) {
|
||||||
|
for _, keyword := range keywords {
|
||||||
|
index := strings.Index(lowerBody, strings.ToLower(keyword))
|
||||||
|
if index < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
start := max(0, index-40)
|
||||||
|
end := min(len(originalBody), index+len(keyword)+80)
|
||||||
|
return strings.TrimSpace(originalBody[start:end]), true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func sourceTypeOrDefault(sourceType string) string {
|
||||||
|
if strings.TrimSpace(sourceType) == "" {
|
||||||
|
return "text"
|
||||||
|
}
|
||||||
|
return sourceType
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) nextID(prefix string) string {
|
||||||
|
return fmt.Sprintf("%s-%d", prefix, s.ids.Add(1))
|
||||||
|
}
|
||||||
53
internal/ontology/service_test.go
Normal file
53
internal/ontology/service_test.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package ontology
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestIngestCreatesSourceBackedCandidates(t *testing.T) {
|
||||||
|
service := NewService(NewMemoryStore())
|
||||||
|
|
||||||
|
result, err := service.Ingest(IngestInput{
|
||||||
|
Title: "Backend interview notes",
|
||||||
|
SourceType: "markdown",
|
||||||
|
Body: "Idempotent API retries need transactions. Cache invalidation uses TTL tradeoffs.",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Ingest error: %v", err)
|
||||||
|
}
|
||||||
|
if result.Material.ID == "" {
|
||||||
|
t.Fatal("expected material id")
|
||||||
|
}
|
||||||
|
if len(result.Snapshot.Concepts) == 0 {
|
||||||
|
t.Fatal("expected concept candidates")
|
||||||
|
}
|
||||||
|
for _, concept := range result.Snapshot.Concepts {
|
||||||
|
if concept.ReviewState != ReviewCandidate {
|
||||||
|
t.Fatalf("review state = %q", concept.ReviewState)
|
||||||
|
}
|
||||||
|
if len(concept.Evidence) == 0 {
|
||||||
|
t.Fatal("expected concept evidence")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(result.Snapshot.Edges) == 0 {
|
||||||
|
t.Fatal("expected prerequisite edge candidates")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIngestMarksGapsAsCandidates(t *testing.T) {
|
||||||
|
service := NewService(NewMemoryStore())
|
||||||
|
|
||||||
|
result, err := service.Ingest(IngestInput{
|
||||||
|
Title: "Cache note",
|
||||||
|
Body: "Cache invalidation is hard.",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Ingest error: %v", err)
|
||||||
|
}
|
||||||
|
if len(result.Snapshot.Gaps) == 0 {
|
||||||
|
t.Fatal("expected gaps")
|
||||||
|
}
|
||||||
|
for _, gap := range result.Snapshot.Gaps {
|
||||||
|
if gap.ReviewState != ReviewCandidate {
|
||||||
|
t.Fatalf("gap review state = %q", gap.ReviewState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
87
internal/ontology/store.go
Normal file
87
internal/ontology/store.go
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
package ontology
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
import "tutor/internal/workflows"
|
||||||
|
|
||||||
|
type Store interface {
|
||||||
|
Save(Material, []ConceptCandidate, []EdgeCandidate, []Gap) error
|
||||||
|
Snapshot() Snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
type MemoryStore struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
materials []Material
|
||||||
|
concepts []ConceptCandidate
|
||||||
|
edges []EdgeCandidate
|
||||||
|
gaps []Gap
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMemoryStore() *MemoryStore {
|
||||||
|
return &MemoryStore{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MemoryStore) Save(
|
||||||
|
material Material,
|
||||||
|
concepts []ConceptCandidate,
|
||||||
|
edges []EdgeCandidate,
|
||||||
|
gaps []Gap,
|
||||||
|
) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
s.materials = append(s.materials, cloneMaterial(material))
|
||||||
|
s.concepts = append(s.concepts, cloneConcepts(concepts)...)
|
||||||
|
s.edges = append(s.edges, cloneEdges(edges)...)
|
||||||
|
s.gaps = append(s.gaps, cloneGaps(gaps)...)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MemoryStore) Snapshot() Snapshot {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
return Snapshot{
|
||||||
|
Materials: cloneMaterials(s.materials),
|
||||||
|
Concepts: cloneConcepts(s.concepts),
|
||||||
|
Edges: cloneEdges(s.edges),
|
||||||
|
Gaps: cloneGaps(s.gaps),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneMaterial(material Material) Material {
|
||||||
|
return material
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneMaterials(items []Material) []Material {
|
||||||
|
cloned := make([]Material, len(items))
|
||||||
|
copy(cloned, items)
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneConcepts(items []ConceptCandidate) []ConceptCandidate {
|
||||||
|
cloned := make([]ConceptCandidate, len(items))
|
||||||
|
for i, item := range items {
|
||||||
|
cloned[i] = item
|
||||||
|
cloned[i].Evidence = append([]workflows.EvidenceRef(nil), item.Evidence...)
|
||||||
|
}
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneEdges(items []EdgeCandidate) []EdgeCandidate {
|
||||||
|
cloned := make([]EdgeCandidate, len(items))
|
||||||
|
for i, item := range items {
|
||||||
|
cloned[i] = item
|
||||||
|
cloned[i].Evidence = append([]workflows.EvidenceRef(nil), item.Evidence...)
|
||||||
|
}
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneGaps(items []Gap) []Gap {
|
||||||
|
cloned := make([]Gap, len(items))
|
||||||
|
for i, item := range items {
|
||||||
|
cloned[i] = item
|
||||||
|
cloned[i].SupportingEvidence = append([]workflows.EvidenceRef(nil), item.SupportingEvidence...)
|
||||||
|
}
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
91
internal/ontology/types.go
Normal file
91
internal/ontology/types.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
package ontology
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"tutor/internal/workflows"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ReviewState string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ReviewCandidate ReviewState = "candidate"
|
||||||
|
ReviewReviewed ReviewState = "reviewed"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Material struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
SourceType string `json:"source_type"`
|
||||||
|
Body string `json:"body,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConceptCandidate struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Concept workflows.ConceptRef `json:"concept"`
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
Evidence []workflows.EvidenceRef `json:"evidence"`
|
||||||
|
ReviewState ReviewState `json:"review_state"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type EdgeCandidate struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
From workflows.ConceptRef `json:"from"`
|
||||||
|
To workflows.ConceptRef `json:"to"`
|
||||||
|
Kind EdgeKind `json:"kind"`
|
||||||
|
Evidence []workflows.EvidenceRef `json:"evidence"`
|
||||||
|
ReviewState ReviewState `json:"review_state"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type EdgeKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
EdgePrerequisite EdgeKind = "prerequisite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Gap struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Concept workflows.ConceptRef `json:"concept"`
|
||||||
|
GapType GapType `json:"gap_type"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
SupportingEvidence []workflows.EvidenceRef `json:"supporting_evidence"`
|
||||||
|
ProposedAction ProposedAction `json:"proposed_action"`
|
||||||
|
ReviewState ReviewState `json:"review_state"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GapType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
GapMissingPrerequisite GapType = "missing_prerequisite"
|
||||||
|
GapWeakEvidence GapType = "weak_evidence"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ProposedAction string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ActionGenerateCandidate ProposedAction = "generate_candidate"
|
||||||
|
ActionRequestSource ProposedAction = "request_source"
|
||||||
|
ActionHumanReview ProposedAction = "human_review"
|
||||||
|
)
|
||||||
|
|
||||||
|
type IngestInput struct {
|
||||||
|
Title string
|
||||||
|
SourceType string
|
||||||
|
Body string
|
||||||
|
}
|
||||||
|
|
||||||
|
type IngestResult struct {
|
||||||
|
Material Material `json:"material"`
|
||||||
|
Snapshot Snapshot `json:"snapshot"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Snapshot struct {
|
||||||
|
Materials []Material `json:"materials"`
|
||||||
|
Concepts []ConceptCandidate `json:"concepts"`
|
||||||
|
Edges []EdgeCandidate `json:"edges"`
|
||||||
|
Gaps []Gap `json:"gaps"`
|
||||||
|
}
|
||||||
@@ -15,3 +15,4 @@
|
|||||||
- [x] 11. Validate the OpenSpec change.
|
- [x] 11. Validate the OpenSpec change.
|
||||||
- [x] 12. Implement evidence-backed learner memory ingestion and readback.
|
- [x] 12. Implement evidence-backed learner memory ingestion and readback.
|
||||||
- [x] 13. Implement evidence-backed readiness map and next challenge APIs.
|
- [x] 13. Implement evidence-backed readiness map and next challenge APIs.
|
||||||
|
- [x] 14. Implement source-backed ontology material ingestion.
|
||||||
|
|||||||
Reference in New Issue
Block a user