Apply ZIP update: 175-didactopus-backend-api-prototype.zip [2026-03-14T13:20:13]

This commit is contained in:
welsberr 2026-03-14 13:29:55 -04:00
parent d851515201
commit efafa2f289
10 changed files with 122 additions and 285 deletions

View File

@ -10,14 +10,11 @@ dependencies = [
"pydantic>=2.7",
"pyyaml>=6.0",
"fastapi>=0.115",
"uvicorn>=0.30",
"sqlalchemy>=2.0",
"passlib[bcrypt]>=1.7"
"uvicorn>=0.30"
]
[project.scripts]
didactopus-api = "didactopus.api:main"
didactopus-seed-db = "didactopus.seed:main"
[tool.setuptools.packages.find]
where = ["src"]

View File

@ -1,19 +1,14 @@
from __future__ import annotations
from fastapi import FastAPI, HTTPException, Header, Depends, BackgroundTasks
from pathlib import Path
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
import uvicorn
from .config import load_settings
from .db import Base, engine
from .models import LoginRequest, LoginResponse, CreateLearnerRequest, LearnerState, EvidenceEvent, EvaluatorSubmission, EvaluatorJobStatus
from .repository import (
authenticate_user, get_user_by_token, list_packs, get_pack, create_learner,
learner_owned_by_user, load_learner_state, save_learner_state,
create_evaluator_job, get_evaluator_job, update_evaluator_job
)
from .models import LearnerState, EvidenceEvent
from .storage import FileStorage
from .engine import apply_evidence, recommend_next
settings = load_settings()
Base.metadata.create_all(bind=engine)
BASE_DIR = Path(__file__).resolve().parents[2] / "data"
storage = FileStorage(BASE_DIR)
app = FastAPI(title="Didactopus API Prototype")
app.add_middleware(
@ -24,112 +19,44 @@ app.add_middleware(
allow_headers=["*"],
)
def current_user(authorization: str = Header(default="")):
token = authorization.removeprefix("Bearer ").strip()
user = get_user_by_token(token) if token else None
if user is None:
raise HTTPException(status_code=401, detail="Unauthorized")
return user
def ensure_learner_access(user, learner_id: str):
if not learner_owned_by_user(user.id, learner_id):
raise HTTPException(status_code=403, detail="Learner not accessible by this user")
def simulate_evaluator_job(job_id: int):
job = get_evaluator_job(job_id)
if job is None:
return
update_evaluator_job(job_id, "running")
score = 0.78 if len(job.submitted_text.strip()) > 20 else 0.62
confidence_hint = 0.72 if len(job.submitted_text.strip()) > 20 else 0.45
notes = "Prototype evaluator: longer responses scored somewhat higher."
update_evaluator_job(job_id, "completed", score=score, confidence_hint=confidence_hint, notes=notes)
state = load_learner_state(job.learner_id)
state = apply_evidence(state, EvidenceEvent(
concept_id=job.concept_id,
dimension="mastery",
score=score,
confidence_hint=confidence_hint,
timestamp="2026-03-13T12:00:00+00:00",
kind="review",
source_id=f"evaluator-job-{job_id}",
))
save_learner_state(state)
@app.post("/api/login", response_model=LoginResponse)
def login(payload: LoginRequest):
user = authenticate_user(payload.username, payload.password)
if user is None:
raise HTTPException(status_code=401, detail="Invalid credentials")
return LoginResponse(token=user.token, username=user.username)
@app.get("/api/packs")
def api_list_packs(user = Depends(current_user)):
return [p.model_dump() for p in list_packs()]
def list_packs():
return [p.model_dump() for p in storage.list_packs()]
@app.get("/api/packs/{pack_id}")
def api_get_pack(pack_id: str, user = Depends(current_user)):
pack = get_pack(pack_id)
def get_pack(pack_id: str):
pack = storage.get_pack(pack_id)
if pack is None:
raise HTTPException(status_code=404, detail="Pack not found")
return pack.model_dump()
@app.post("/api/learners")
def api_create_learner(payload: CreateLearnerRequest, user = Depends(current_user)):
create_learner(user.id, payload.learner_id, payload.display_name)
return {"ok": True, "learner_id": payload.learner_id}
@app.get("/api/learners/{learner_id}/state")
def api_get_learner_state(learner_id: str, user = Depends(current_user)):
ensure_learner_access(user, learner_id)
return load_learner_state(learner_id).model_dump()
def get_learner_state(learner_id: str):
return storage.get_learner_state(learner_id).model_dump()
@app.put("/api/learners/{learner_id}/state")
def api_put_learner_state(learner_id: str, state: LearnerState, user = Depends(current_user)):
ensure_learner_access(user, learner_id)
def put_learner_state(learner_id: str, state: LearnerState):
if learner_id != state.learner_id:
raise HTTPException(status_code=400, detail="Learner ID mismatch")
return save_learner_state(state).model_dump()
return storage.save_learner_state(state).model_dump()
@app.post("/api/learners/{learner_id}/evidence")
def api_post_evidence(learner_id: str, event: EvidenceEvent, user = Depends(current_user)):
ensure_learner_access(user, learner_id)
state = load_learner_state(learner_id)
def post_evidence(learner_id: str, event: EvidenceEvent):
state = storage.get_learner_state(learner_id)
state = apply_evidence(state, event)
save_learner_state(state)
storage.save_learner_state(state)
return state.model_dump()
@app.get("/api/learners/{learner_id}/recommendations/{pack_id}")
def api_get_recommendations(learner_id: str, pack_id: str, user = Depends(current_user)):
ensure_learner_access(user, learner_id)
state = load_learner_state(learner_id)
pack = get_pack(pack_id)
def get_recommendations(learner_id: str, pack_id: str):
state = storage.get_learner_state(learner_id)
pack = storage.get_pack(pack_id)
if pack is None:
raise HTTPException(status_code=404, detail="Pack not found")
return {"cards": recommend_next(state, pack)}
@app.post("/api/learners/{learner_id}/evaluator-jobs", response_model=EvaluatorJobStatus)
def api_submit_evaluator_job(learner_id: str, payload: EvaluatorSubmission, background_tasks: BackgroundTasks, user = Depends(current_user)):
ensure_learner_access(user, learner_id)
job_id = create_evaluator_job(learner_id, payload.pack_id, payload.concept_id, payload.submitted_text)
background_tasks.add_task(simulate_evaluator_job, job_id)
return EvaluatorJobStatus(job_id=job_id, status="queued")
@app.get("/api/evaluator-jobs/{job_id}", response_model=EvaluatorJobStatus)
def api_get_evaluator_job(job_id: int, user = Depends(current_user)):
job = get_evaluator_job(job_id)
if job is None:
raise HTTPException(status_code=404, detail="Job not found")
return EvaluatorJobStatus(
job_id=job.id,
status=job.status,
result_score=job.result_score,
result_confidence_hint=job.result_confidence_hint,
result_notes=job.result_notes,
)
def main():
uvicorn.run(app, host=settings.host, port=settings.port)
uvicorn.run(app, host="127.0.0.1", port=8011)
if __name__ == "__main__":
main()

View File

@ -48,28 +48,3 @@ class LearnerState(BaseModel):
learner_id: str
records: list[MasteryRecord] = Field(default_factory=list)
history: list[EvidenceEvent] = Field(default_factory=list)
class LoginRequest(BaseModel):
username: str
password: str
class LoginResponse(BaseModel):
token: str
username: str
class CreateLearnerRequest(BaseModel):
learner_id: str
display_name: str = ""
class EvaluatorSubmission(BaseModel):
pack_id: str
concept_id: str
submitted_text: str
kind: str = "checkpoint"
class EvaluatorJobStatus(BaseModel):
job_id: int
status: str
result_score: float | None = None
result_confidence_hint: float | None = None
result_notes: str = ""

View File

@ -2,16 +2,9 @@ from pathlib import Path
from didactopus.pack_to_frontend import convert_pack
def test_convert_pack(tmp_path: Path):
(tmp_path / "pack.yaml").write_text(
"name: p1\ndisplay_name: Pack 1\ndescription: Demo pack\n", encoding="utf-8"
)
(tmp_path / "concepts.yaml").write_text(
"concepts:\n - id: c1\n title: Concept 1\n prerequisites: []\n", encoding="utf-8"
)
(tmp_path / "pack_compliance_manifest.json").write_text(
'{"derived_from_sources":["s1"],"attribution_required":true,"share_alike_required":false,"noncommercial_only":false,"restrictive_flags":[]}',
encoding="utf-8"
)
(tmp_path / "pack.yaml").write_text("name: p1\ndisplay_name: Pack 1\ndescription: Demo pack\n", encoding="utf-8")
(tmp_path / "concepts.yaml").write_text("concepts:\n - id: c1\n title: Concept 1\n prerequisites: []\n", encoding="utf-8")
(tmp_path / "pack_compliance_manifest.json").write_text('{"derived_from_sources":["s1"],"attribution_required":true,"share_alike_required":false,"noncommercial_only":false,"restrictive_flags":[]}', encoding="utf-8")
payload = convert_pack(tmp_path)
assert payload["id"] == "p1"
assert payload["concepts"][0]["id"] == "c1"

View File

@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Didactopus Auth API UI</title>
<title>Didactopus API UI</title>
<script type="module" src="/src/main.jsx"></script>
</head>
<body>

View File

@ -1,5 +1,5 @@
{
"name": "didactopus-auth-api-ui",
"name": "didactopus-api-ui",
"private": true,
"version": "0.1.0",
"type": "module",

View File

@ -1,38 +1,16 @@
import React, { useEffect, useMemo, useState } from "react";
import { login, fetchPacks, createLearner, fetchLearnerState, fetchRecommendations, postEvidence, submitEvaluatorJob, fetchEvaluatorJob } from "./api";
import { buildMasteryMap, progressPercent } from "./localEngine";
function LoginPanel({ onLogin }) {
const [username, setUsername] = useState("wesley");
const [password, setPassword] = useState("demo-pass");
const [error, setError] = useState("");
async function handleLogin() {
try {
setError("");
const result = await onLogin(username, password);
return result;
} catch (e) {
setError("Login failed");
}
}
return (
<section className="card narrow">
<h1>Didactopus login</h1>
<label>Username<input value={username} onChange={(e) => setUsername(e.target.value)} /></label>
<label>Password<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} /></label>
<button className="primary" onClick={handleLogin}>Login</button>
{error ? <div className="error">{error}</div> : null}
</section>
);
}
import { fetchPacks, fetchLearnerState, fetchRecommendations, postEvidence } from "./api";
import { buildMasteryMap, progressPercent, milestoneMessages, claimReadiness } from "./localEngine";
function DomainCard({ domain, selected, onSelect }) {
return (
<button className={`domain-card ${selected ? "selected" : ""}`} onClick={() => onSelect(domain.id)}>
<div className="domain-title">{domain.title}</div>
<div className="domain-subtitle">{domain.subtitle}</div>
<div className="domain-meta">
<span>{domain.level}</span>
<span>{domain.concepts.length} concepts</span>
</div>
</button>
);
}
@ -52,51 +30,49 @@ function NextStepCard({ step, onSimulate }) {
<summary>Why this is recommended</summary>
<ul>{step.why.map((item, idx) => <li key={idx}>{item}</li>)}</ul>
</details>
<div className="button-row">
<button className="primary" onClick={() => onSimulate(step)}>Simulate step</button>
</div>
<button className="primary" onClick={() => onSimulate(step)}>Simulate completing this step</button>
</div>
);
}
export default function App() {
const [token, setToken] = useState("");
const [username, setUsername] = useState("");
const [packs, setPacks] = useState([]);
const [selectedDomainId, setSelectedDomainId] = useState("");
const [learnerId, setLearnerId] = useState("wesley-learner");
const [learnerName, setLearnerName] = useState("Wesley");
const [learnerState, setLearnerState] = useState(null);
const [cards, setCards] = useState([]);
const [jobStatus, setJobStatus] = useState(null);
const [lastReward, setLastReward] = useState("");
const learnerId = "wesley-demo";
async function handleLogin(user, pass) {
const auth = await login(user, pass);
setToken(auth.token);
setUsername(auth.username);
await createLearner(auth.token, learnerId, learnerId);
const loadedPacks = await fetchPacks(auth.token);
useEffect(() => {
async function load() {
const loadedPacks = await fetchPacks();
setPacks(loadedPacks);
setSelectedDomainId(loadedPacks[0]?.id || "");
const state = await fetchLearnerState(auth.token, learnerId);
const first = loadedPacks[0]?.id || "";
setSelectedDomainId(first);
const state = await fetchLearnerState(learnerId);
setLearnerState(state);
return auth;
}
load();
}, []);
useEffect(() => {
async function loadCards() {
if (!token || !selectedDomainId) return;
const data = await fetchRecommendations(token, learnerId, selectedDomainId);
if (!selectedDomainId) return;
const data = await fetchRecommendations(learnerId, selectedDomainId);
setCards(data.cards || []);
}
loadCards();
}, [token, learnerId, selectedDomainId, learnerState]);
if (selectedDomainId) loadCards();
}, [selectedDomainId, learnerState]);
const domain = useMemo(() => packs.find((d) => d.id === selectedDomainId) || null, [packs, selectedDomainId]);
const masteryMap = useMemo(() => domain && learnerState ? buildMasteryMap(learnerState, domain) : [], [learnerState, domain]);
const progress = useMemo(() => domain && learnerState ? progressPercent(learnerState, domain) : 0, [learnerState, domain]);
const milestones = useMemo(() => domain && learnerState ? milestoneMessages(learnerState, domain) : [], [learnerState, domain]);
const readiness = useMemo(() => domain && learnerState ? claimReadiness(learnerState, domain) : {ready:false, mastered:0, avgScore:0, avgConfidence:0}, [learnerState, domain]);
async function simulateStep(step) {
const nextState = await postEvidence(token, learnerId, {
const nextState = await postEvidence(learnerId, {
concept_id: step.conceptId,
dimension: "mastery",
score: step.scoreHint,
@ -106,32 +82,11 @@ export default function App() {
source_id: `ui-${step.id}`
});
setLearnerState(nextState);
}
async function submitDemoEvaluator() {
if (!domain) return;
const firstConcept = domain.concepts[0]?.id;
const job = await submitEvaluatorJob(token, learnerId, {
pack_id: domain.id,
concept_id: firstConcept,
submitted_text: "This is a moderately detailed learner response intended to trigger a somewhat better prototype evaluator score.",
kind: "checkpoint"
});
setJobStatus(job);
setTimeout(async () => {
const refreshed = await fetchEvaluatorJob(token, job.job_id);
setJobStatus(refreshed);
const state = await fetchLearnerState(token, learnerId);
setLearnerState(state);
}, 1200);
}
if (!token) {
return <div className="page"><LoginPanel onLogin={handleLogin} /></div>;
setLastReward(step.reward);
}
if (!domain || !learnerState) {
return <div className="page"><div className="card">Loading authenticated learner view...</div></div>;
return <div className="page"><div className="card">Loading backend data...</div></div>;
}
return (
@ -139,12 +94,13 @@ export default function App() {
<header className="hero">
<div>
<h1>Didactopus learner prototype</h1>
<p>Authenticated multi-user scaffold with DB-backed state and async evaluator jobs.</p>
<div className="muted">Signed in as {username}</div>
<p>Backend-driven pack registry, learner-state persistence, and evaluator-style evidence ingestion.</p>
</div>
<div className="hero-controls">
<label>Learner ID<input value={learnerId} onChange={(e) => setLearnerId(e.target.value)} /></label>
<button onClick={submitDemoEvaluator}>Submit demo evaluator job</button>
<label>
Learner name
<input value={learnerName} onChange={(e) => setLearnerName(e.target.value)} />
</label>
</div>
</header>
@ -155,9 +111,10 @@ export default function App() {
<main className="layout">
<div className="left-col">
<section className="card">
<h2>Onboarding</h2>
<h2>First-session onboarding</h2>
<h3>{domain.onboarding.headline}</h3>
<p>{domain.onboarding.body}</p>
<p className="muted">Learner: {learnerName}</p>
<ul>{(domain.onboarding.checklist || []).map((item, idx) => <li key={idx}>{item}</li>)}</ul>
</section>
@ -174,16 +131,13 @@ export default function App() {
</section>
<section className="card">
<h2>Async evaluator job</h2>
{jobStatus ? (
<div>
<div><strong>Status:</strong> {jobStatus.status}</div>
<div><strong>Score:</strong> {jobStatus.result_score ?? "-"}</div>
<div><strong>Confidence hint:</strong> {jobStatus.result_confidence_hint ?? "-"}</div>
<div className="muted">{jobStatus.result_notes || ""}</div>
</div>
) : (
<div className="muted">No evaluator job submitted yet.</div>
<h2>Evidence log</h2>
{learnerState.history.length === 0 ? <div className="muted">No evidence recorded yet.</div> : (
<ul>
{learnerState.history.slice().reverse().map((item, idx) => (
<li key={idx}>{item.concept_id} · score {item.score.toFixed(2)} · confidence hint {item.confidence_hint.toFixed(2)}</li>
))}
</ul>
)}
</section>
</div>
@ -207,27 +161,31 @@ export default function App() {
<div className="progress-bar"><div className="progress-fill" style={{ width: `${progress}%` }} /></div>
<div className="muted">{progress}%</div>
</div>
<div className={`readiness-box ${readiness.ready ? "ready" : ""}`}>
<strong>{readiness.ready ? "Usable expertise threshold met" : "Still building toward usable expertise"}</strong>
<div className="muted">Mastered concepts: {readiness.mastered}</div>
<div className="muted">Average score: {readiness.avgScore.toFixed(2)}</div>
<div className="muted">Average confidence: {readiness.avgConfidence.toFixed(2)}</div>
</div>
</section>
<section className="card">
<h2>Evidence log</h2>
{learnerState.history.length === 0 ? <div className="muted">No evidence recorded yet.</div> : (
<ul>
{learnerState.history.slice().reverse().map((item, idx) => (
<li key={idx}>{item.concept_id} · score {item.score.toFixed(2)} · hint {item.confidence_hint.toFixed(2)} · {item.kind}</li>
))}
</ul>
)}
<h2>Milestones and rewards</h2>
{lastReward ? <div className="reward-banner">{lastReward}</div> : null}
<ul>{milestones.map((m, idx) => <li key={idx}>{m}</li>)}</ul>
</section>
<section className="card">
<h2>Compliance</h2>
<h2>Source attribution and compliance</h2>
<div className="compliance-grid">
<div><strong>Sources</strong><br />{domain.compliance.sources}</div>
<div><strong>Attribution</strong><br />{domain.compliance.attributionRequired ? "required" : "not required"}</div>
<div><strong>Share-alike</strong><br />{domain.compliance.shareAlikeRequired ? "yes" : "no"}</div>
<div><strong>Noncommercial</strong><br />{domain.compliance.noncommercialOnly ? "yes" : "no"}</div>
</div>
<div className="flag-row">
{domain.compliance.flags.length ? domain.compliance.flags.map((f) => <span className="flag" key={f}>{f}</span>) : <span className="muted">No extra flags</span>}
</div>
</section>
</div>
</main>

View File

@ -1,62 +1,25 @@
const API = "http://127.0.0.1:8011/api";
export async function login(username, password) {
const res = await fetch(`${API}/login`, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({ username, password })
});
if (!res.ok) throw new Error("Login failed");
export async function fetchPacks() {
const res = await fetch(`${API}/packs`);
return await res.json();
}
function authHeaders(token) {
return { "Content-Type": "application/json", "Authorization": `Bearer ${token}` };
}
export async function fetchPacks(token) {
const res = await fetch(`${API}/packs`, { headers: authHeaders(token) });
export async function fetchLearnerState(learnerId) {
const res = await fetch(`${API}/learners/${learnerId}/state`);
return await res.json();
}
export async function createLearner(token, learnerId, displayName) {
const res = await fetch(`${API}/learners`, {
method: "POST",
headers: authHeaders(token),
body: JSON.stringify({ learner_id: learnerId, display_name: displayName })
});
return await res.json();
}
export async function fetchLearnerState(token, learnerId) {
const res = await fetch(`${API}/learners/${learnerId}/state`, { headers: authHeaders(token) });
return await res.json();
}
export async function postEvidence(token, learnerId, event) {
export async function postEvidence(learnerId, event) {
const res = await fetch(`${API}/learners/${learnerId}/evidence`, {
method: "POST",
headers: authHeaders(token),
headers: {"Content-Type": "application/json"},
body: JSON.stringify(event)
});
return await res.json();
}
export async function fetchRecommendations(token, learnerId, packId) {
const res = await fetch(`${API}/learners/${learnerId}/recommendations/${packId}`, { headers: authHeaders(token) });
return await res.json();
}
export async function submitEvaluatorJob(token, learnerId, payload) {
const res = await fetch(`${API}/learners/${learnerId}/evaluator-jobs`, {
method: "POST",
headers: authHeaders(token),
body: JSON.stringify(payload)
});
return await res.json();
}
export async function fetchEvaluatorJob(token, jobId) {
const res = await fetch(`${API}/evaluator-jobs/${jobId}`, { headers: authHeaders(token) });
export async function fetchRecommendations(learnerId, packId) {
const res = await fetch(`${API}/learners/${learnerId}/recommendations/${packId}`);
return await res.json();
}

View File

@ -22,3 +22,27 @@ export function progressPercent(state, domain) {
const mastered = domain.concepts.filter((c) => conceptStatus(state, c) === "mastered").length;
return Math.round((mastered / total) * 100);
}
export function milestoneMessages(state, domain) {
const msgs = [];
for (const concept of domain.concepts) {
if (conceptStatus(state, concept) === "mastered") msgs.push(`${concept.title} mastered`);
}
if (msgs.length === 0) msgs.push("Complete your first guided exercise to earn a visible mastery marker");
return msgs;
}
export function claimReadiness(state, domain, minScore = 0.75, minConfidence = 0.60) {
const records = domain.concepts
.map((c) => getRecord(state, c.id, c.masteryDimension || "mastery"))
.filter(Boolean);
const mastered = records.filter((r) => r.score >= minScore && r.confidence >= minConfidence).length;
const avgScore = records.length ? records.reduce((a, r) => a + r.score, 0) / records.length : 0;
const avgConfidence = records.length ? records.reduce((a, r) => a + r.confidence, 0) / records.length : 0;
return {
ready: mastered >= Math.max(1, domain.concepts.length - 1) && avgScore >= minScore && avgConfidence >= minConfidence,
mastered,
avgScore,
avgConfidence
};
}

View File

@ -21,18 +21,13 @@ body { margin: 0; font-family: Arial, Helvetica, sans-serif; background: var(--b
.domain-meta { margin-top: 10px; display: flex; gap: 12px; color: var(--muted); font-size: 14px; }
.layout { display: grid; grid-template-columns: 1fr 1.25fr 0.95fr; gap: 16px; margin-top: 16px; }
.card { background: var(--card); border: 1px solid var(--border); border-radius: 20px; padding: 18px; }
.narrow { max-width: 420px; margin: 60px auto; }
label { display: block; font-weight: 600; margin-bottom: 12px; }
input { width: 100%; margin-top: 6px; border: 1px solid var(--border); border-radius: 10px; padding: 10px; font: inherit; }
.muted { color: var(--muted); }
.error { color: #b42318; margin-top: 10px; }
.steps-stack { display: grid; gap: 14px; }
.step-card { border: 1px solid var(--border); border-radius: 16px; padding: 14px; background: #fcfdff; }
.step-header { display: flex; justify-content: space-between; gap: 12px; align-items: start; }
.reward-pill { background: var(--soft); border: 1px solid var(--border); border-radius: 999px; padding: 8px 10px; font-size: 12px; }
button { border: 1px solid var(--border); background: white; border-radius: 12px; padding: 10px 14px; cursor: pointer; }
.primary { background: var(--accent); color: white; border: none; }
.button-row { display: flex; gap: 10px; }
.primary { margin-top: 10px; background: var(--accent); color: white; border: none; }
.map-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
.map-node { border: 1px solid var(--border); border-radius: 16px; padding: 14px; }
.map-node.mastered { background: #eef9f0; }
@ -43,7 +38,12 @@ button { border: 1px solid var(--border); background: white; border-radius: 12px
.progress-wrap { margin-bottom: 14px; }
.progress-bar { width: 100%; height: 12px; border-radius: 999px; background: #e9edf4; overflow: hidden; margin: 8px 0; }
.progress-fill { height: 100%; background: var(--accent); }
.reward-banner { background: #fff7dd; border: 1px solid #ecdca2; border-radius: 14px; padding: 12px; margin-bottom: 12px; font-weight: 700; }
.readiness-box { border: 1px solid var(--border); background: #fbfcfe; border-radius: 14px; padding: 12px; }
.readiness-box.ready { background: #eef9f0; }
.compliance-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
.flag-row { margin-top: 12px; display: flex; flex-wrap: wrap; gap: 8px; }
.flag { border: 1px solid var(--border); background: #f4f7fc; border-radius: 999px; padding: 6px 10px; font-size: 12px; }
details summary { cursor: pointer; color: var(--accent); }
@media (max-width: 1100px) {
.layout { grid-template-columns: 1fr; }