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
22 changes: 22 additions & 0 deletions src/cognit/engine/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,32 @@
from pydantic import BaseModel, Field, model_validator


class Anchor(BaseModel):
"""Where in the diff a question is probing, so the browser can show the hunk inline.

Optional on every question (defaults to None) — old cached quizzes load unchanged.
A hint, not an assertion the lines are part of the diff: a question may anchor
surrounding unchanged context the reader needs."""

path: str # repo-relative path, from changed_files
start_line: int = Field(ge=1)
end_line: int = Field(ge=1)

@model_validator(mode="after")
def _ordered(self) -> "Anchor":
if self.end_line < self.start_line:
raise ValueError(f"end_line {self.end_line} < start_line {self.start_line}")
return self


class MCQQuestion(BaseModel):
type: Literal["mcq"] = "mcq"
id: str
prompt: str
options: list[str]
answer: str # must equal one of options
explanation: str = "" # shown to the reader after they answer (the "aha")
anchor: Anchor | None = None # diff hunk to show inline (optional)

@model_validator(mode="after")
def _answer_in_options(self) -> "MCQQuestion":
Expand All @@ -24,6 +43,7 @@ class MermaidQuestion(BaseModel):
options: dict[str, str] # label -> mermaid source
answer: str # must be a key of options
explanation: str = "" # shown to the reader after they answer (the "aha")
anchor: Anchor | None = None # diff hunk to show inline (optional)

@model_validator(mode="after")
def _answer_is_option_key(self) -> "MermaidQuestion":
Expand All @@ -37,6 +57,7 @@ class OpenQuestion(BaseModel):
id: str
prompt: str
rubric: str
anchor: Anchor | None = None # diff hunk to show inline (optional)


class TrueFalseQuestion(BaseModel):
Expand All @@ -45,6 +66,7 @@ class TrueFalseQuestion(BaseModel):
prompt: str
answer: bool
explanation: str = "" # shown to the reader after they answer (the "aha")
anchor: Anchor | None = None # diff hunk to show inline (optional)


Question = Annotated[
Expand Down
8 changes: 8 additions & 0 deletions src/cognit/engine/prompts/system_generate.txt
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,14 @@ Each question is one of these four shapes. **Watch the `answer` field — its ty
- **`tf`** — `answer` is a JSON **boolean** (`true`/`false`), not a string.
- **`open`** — requires a `rubric`; it has **no `answer`** and **no `explanation`**.

**Anchor each question to its code.** Whenever a question probes specific lines, add an `anchor` so the reader sees that exact hunk inline next to the question:

```json
"anchor": {"path": "<a path from changed_files>", "start_line": N, "end_line": M}
```

Use the new-side line numbers of the relevant lines (`start_line` ≤ `end_line`; a single line is `start == end`). Prefer the tightest range that frames the behavior — the few lines that make the question answerable, not the whole file. Omit `anchor` only for questions not tied to one location (e.g. a broad `open` rationale question spanning the change). The anchor drives an inline code panel; it never affects grading. It works on any question type and is added alongside the fields above.

When the reader asks you to change or skip a specific question, use `replace_question(index, question)` (0-based index) — same per-type shapes as above.

When the reader says they're ready to be graded, call `grade`.
Expand Down
116 changes: 115 additions & 1 deletion src/cognit/mcp/assets/quiz_mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ function quizSig(q) {
return JSON.stringify(q.questions.map((x) => [
x.id, x.type, x.prompt,
x.type === "mcq" ? x.options : x.type === "mermaid" ? Object.keys(x.options) : null,
x.anchor ? [x.anchor.path, x.anchor.start_line, x.anchor.end_line] : null,
]));
}

Expand Down Expand Up @@ -238,6 +239,118 @@ function renderTF(q) {
return [wrap];
}

// ── inline code context (anchors) ───────────────────────────────
// A collapsible panel under a question that shows the anchored diff hunk. The hunk
// is fetched from GET /diff on first expand and rendered DOM-built (textContent only,
// never innerHTML) so agent/repo-supplied diff text can't inject markup.
const _diffCache = {}; // path -> diff text (one fetch per file per page)

// Split a unified-diff file section into the lines before the first hunk (file header)
// and the hunks. Each hunk carries its new-side line span so we can scope to an anchor.
function parseDiff(text) {
const hunks = [];
let cur = null;
for (const line of String(text).split("\n")) {
if (line.startsWith("@@")) {
const m = /@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(line);
const startNew = m ? parseInt(m[1], 10) : 1;
const countNew = m && m[2] != null ? parseInt(m[2], 10) : 1;
cur = { startNew, endNew: startNew + Math.max(countNew, 1) - 1, lines: [line] };
hunks.push(cur);
} else if (cur) {
cur.lines.push(line);
}
}
return hunks;
}

function diffLineNode(line, lineNo, anchor) {
let cls = "diff-line";
if (line.startsWith("@@")) cls += " hunk";
else if (/^(diff |index |\+\+\+|---)/.test(line)) cls += " fmeta";
else if (line.startsWith("+")) cls += " add";
else if (line.startsWith("-")) cls += " del";
if (anchor && lineNo != null && lineNo >= anchor.start_line && lineNo <= anchor.end_line) {
cls += " anchor-hit";
}
return el("div", { class: cls, text: line === "" ? " " : line });
}

// Render the diff, scoped to the hunk(s) the anchor points at (new-side line range).
// Falls back to all hunks if the anchor overlaps none (e.g. it points at context the
// diff doesn't touch). Anchored new-side lines are highlighted. textContent only.
function renderDiff(text, anchor) {
const hunks = parseDiff(text);
let shown = hunks;
if (anchor && hunks.length) {
const overlap = hunks.filter(
(h) => h.startNew <= anchor.end_line && h.endNew >= anchor.start_line
);
if (overlap.length) shown = overlap;
}
const pre = el("pre", { class: "diff" });
if (!shown.length) { // no @@ hunks (binary/empty section) — show raw, no numbering
String(text).split("\n").forEach((line) => pre.appendChild(diffLineNode(line, null, null)));
return pre;
}
shown.forEach((h) => {
let newLine = h.startNew;
h.lines.forEach((line) => {
if (line.startsWith("@@")) {
pre.appendChild(diffLineNode(line, null, anchor));
} else if (line.startsWith("-")) {
pre.appendChild(diffLineNode(line, null, anchor)); // removed: no new-side number
} else {
pre.appendChild(diffLineNode(line, newLine, anchor)); // context / added
newLine++;
}
});
});
return pre;
}

async function loadHunk(path, anchor, body) {
body.textContent = "Loading…";
let text;
try {
if (!(path in _diffCache)) {
_diffCache[path] = await (await fetch("/diff?path=" + encodeURIComponent(path))).text();
}
text = _diffCache[path];
} catch (e) {
console.warn("diff fetch failed for", path, e);
body.textContent = "Could not load the diff for this file.";
return;
}
body.textContent = "";
if (/^No changed file matches/.test(text)) {
// path isn't in the PR diff (e.g. renamed, or a filtered binary/minified file)
body.appendChild(el("div", { class: "codepanel__note", text: "Not part of the PR diff." }));
return;
}
body.appendChild(renderDiff(text, anchor));
}

function renderAnchor(q) {
const a = q.anchor;
if (!a || !a.path) return null;
const range = a.start_line === a.end_line ? `${a.start_line}` : `${a.start_line}–${a.end_line}`;
const body = el("div", { class: "codepanel__body" });
const details = el("details", { class: "codepanel" }, [
el("summary", { class: "codepanel__summary" }, [
el("span", { class: "codepanel__icon", "aria-hidden": "true", text: "▸" }),
el("span", { class: "codepanel__file", text: a.path }),
el("span", { class: "codepanel__lines", text: `:${range}` }),
]),
body,
]);
let loaded = false;
details.addEventListener("toggle", () => {
if (details.open && !loaded) { loaded = true; loadHunk(a.path, a, body); }
});
return details;
}

function renderQuestion(q, i) {
const inputsByType = { mcq: renderMCQ, mermaid: renderMermaid, open: renderOpen, tf: renderTF };
const inputs = inputsByType[q.type](q);
Expand All @@ -248,6 +361,7 @@ function renderQuestion(q, i) {
]),
el("div", { class: "file__body" }, [
el("p", { class: "prompt" }, renderPrompt(q.prompt)),
renderAnchor(q), // null when no anchor — el() skips null children
...inputs,
]),
]);
Expand Down Expand Up @@ -357,7 +471,7 @@ function renderSummary(res) {
function renderResultCard(q, r, i) {
const cls = scoreClass(r.score);
const verdict = cls === "ok" ? "correct" : cls === "bad" ? "incorrect" : "partial";
const body = [el("p", { class: "prompt" }, renderPrompt(q.prompt))];
const body = [el("p", { class: "prompt" }, renderPrompt(q.prompt)), renderAnchor(q)];
const userVal = answers[q.id];
if (q.type === "mcq" || q.type === "tf") {
const correctAnswer = q.type === "tf" ? String(q.answer) : q.answer;
Expand Down
52 changes: 52 additions & 0 deletions src/cognit/mcp/assets/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -820,3 +820,55 @@ textarea.open:focus { border-color: var(--blue); outline: none; box-shadow: 0 0
.gen__spinner { animation: none; }
.term::after { animation: none; }
}

/* inline code context (anchors) ──────────────────────────────────── */
.codepanel {
margin: 0 0 16px;
border: 1px solid var(--border-mute);
border-radius: 6px;
background: var(--bg-subtle);
}
.codepanel__summary {
display: flex;
align-items: baseline;
gap: 4px;
padding: 8px 12px;
cursor: pointer;
font-family: var(--mono);
font-size: 12px;
color: var(--fg-mute);
list-style: none;
user-select: none;
}
.codepanel__summary::-webkit-details-marker { display: none; }
.codepanel__icon { color: var(--fg-faint); transition: transform 0.12s ease; display: inline-block; }
.codepanel[open] > .codepanel__summary .codepanel__icon { transform: rotate(90deg); }
.codepanel__file { color: var(--fg); font-weight: 500; }
.codepanel__lines { color: var(--fg-faint); }
.codepanel__summary:hover .codepanel__file { color: var(--blue); }
.codepanel__body { border-top: 1px solid var(--border-mute); }
.codepanel pre.diff {
margin: 0;
padding: 8px 0;
overflow-x: auto;
font-family: var(--mono);
font-size: 12px;
line-height: 1.55;
background: var(--bg-canvas);
border-radius: 0 0 6px 6px;
}
.diff-line {
padding: 0 12px;
white-space: pre;
color: var(--fg);
}
.diff-line.add { background: var(--green-bg); }
.diff-line.del { background: var(--red-bg); }
.diff-line.hunk { color: var(--blue); background: var(--blue-bg); }
.diff-line.fmeta { color: var(--fg-faint); }
.diff-line.anchor-hit { box-shadow: inset 3px 0 0 var(--blue); }
.codepanel__note { padding: 10px 12px; font-size: 12px; color: var(--fg-mute); background: var(--bg-canvas); border-radius: 0 0 6px 6px; }

@media (prefers-reduced-motion: reduce) {
.codepanel__icon { transition: none; }
}
13 changes: 8 additions & 5 deletions src/cognit/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,8 @@ def overview(self) -> str:
# ── process wiring ───────────────────────────────────────────────────────────


def _build_mcp(state: QuizState, llm: LLMClient, pr_url: str) -> FastMCP:
def _build_mcp(state: QuizState, llm: LLMClient, diffs: _DiffProvider) -> FastMCP:
mcp = FastMCP("cognit")
diffs = _DiffProvider(pr_url)

@mcp.tool()
async def set_quiz(quiz: dict[str, Any]) -> dict[str, Any]:
Expand Down Expand Up @@ -161,11 +160,14 @@ def file_diff(path: str) -> str:
return mcp


def _start_web(state: QuizState, *, llm: LLMClient, pr_url: str, port: int) -> None:
def _start_web(
state: QuizState, *, llm: LLMClient, pr_url: str, port: int, diffs: _DiffProvider
) -> None:
app = build_web_app(
state,
post_comment=lambda body: gh_post_comment(pr_url, body),
grade=lambda: grade_state(state, llm=llm),
diff_section=lambda path: do_file_diff(path, diffs.sections()),
pr_url=pr_url,
)
url = f"http://127.0.0.1:{port}"
Expand Down Expand Up @@ -222,10 +224,11 @@ def main() -> None:

print(f"cognit quiz: http://127.0.0.1:{port}", file=sys.stderr)
llm: LLMClient = ClaudeAgentLLM()
diffs = _DiffProvider(pr_url) # one fetch/cache shared by the MCP tools and the web /diff
threading.Thread(
target=_start_web,
args=(state,),
kwargs={"llm": llm, "pr_url": pr_url, "port": port},
kwargs={"llm": llm, "pr_url": pr_url, "port": port, "diffs": diffs},
daemon=True,
).start()
_build_mcp(state, llm, pr_url).run()
_build_mcp(state, llm, diffs).run()
13 changes: 11 additions & 2 deletions src/cognit/mcp/web.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@

from fastapi import FastAPI, Request
from fastapi.concurrency import run_in_threadpool
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
from fastapi.staticfiles import StaticFiles

from cognit.comment.render import render_results_inlined
Expand All @@ -38,13 +38,16 @@ def build_web_app(
*,
post_comment: Callable[[str], str],
grade: Callable[[], Results] | None = None,
diff_section: Callable[[str], str] | None = None,
pr_url: str = "",
) -> FastAPI:
"""Browser projection over `state`.

`grade` (when provided) is invoked by POST /grade — the human-clicked "Submit quiz"
button. It runs the same handler-owned grading the agent's `grade` tool uses and
must store the result in `state`; we return the Results to the page. `pr_url` feeds
must store the result in `state`; we return the Results to the page. `diff_section`
(when provided) returns the unified-diff section for one changed file — GET /diff
serves it so the browser can show a question's anchored hunk inline. `pr_url` feeds
the page chrome (the "on GitHub" links).
"""
app = FastAPI()
Expand Down Expand Up @@ -73,6 +76,12 @@ async def post_answer(req: Request) -> JSONResponse:
state.record_answer(qid, value)
return JSONResponse({"ok": True})

@app.get("/diff", response_class=PlainTextResponse)
def get_diff(path: str = "") -> PlainTextResponse:
if diff_section is None:
return PlainTextResponse("diff not available", status_code=503)
return PlainTextResponse(diff_section(path))

@app.post("/grade")
async def do_grade() -> JSONResponse:
if grade is None:
Expand Down
Loading
Loading