Kattine commited on
Commit
006a1d9
·
1 Parent(s): d0e1d46

Polish: concept top-2 calibration, cross-cutting tag, uncertainty flag, overall depth bar

Browse files
Files changed (3) hide show
  1. frontend/index.html +73 -19
  2. main.py +17 -23
  3. scripts/inference.py +14 -15
frontend/index.html CHANGED
@@ -35,7 +35,7 @@
35
  }
36
  .mono { font-family: 'IBM Plex Mono', monospace; }
37
 
38
- /* ---- header ---- */
39
  header {
40
  display: flex; align-items: baseline; justify-content: space-between;
41
  padding: 22px 32px 18px; color: var(--paper);
@@ -58,7 +58,7 @@
58
  }
59
  .reset:hover { color: var(--paper); border-color: rgba(237,234,226,.5); }
60
 
61
- /* provider toggle */
62
  .provider-pick {
63
  display: flex; align-items: center; gap: 8px;
64
  }
@@ -75,7 +75,7 @@
75
  .seg-toggle button.active { background: var(--crit); color: #fff; }
76
  .seg-toggle button:not(.active):hover { color: var(--paper); }
77
 
78
- /* ---- workspace ---- */
79
  .workspace {
80
  display: grid; grid-template-columns: minmax(300px, 38%) 1fr;
81
  gap: 22px; padding: 22px 32px 32px; height: calc(100vh - 73px);
@@ -99,7 +99,7 @@
99
  .panel-meta { font-family:'IBM Plex Mono',monospace; font-size: 11px; color: var(--ink-soft); }
100
  .panel-body { flex: 1; overflow-y: auto; padding: 20px; }
101
 
102
- /* ---- left: setup ---- */
103
  .dropzone {
104
  border: 1.5px dashed var(--rule-strong); border-radius: 3px;
105
  padding: 28px 18px; text-align: center; cursor: pointer; transition: .15s;
@@ -132,7 +132,19 @@
132
  .btn-block { width: 100%; margin-top: 14px; }
133
  .filename { font-size: 13px; color: var(--crit-ink); margin-top: 10px; font-weight: 500; }
134
 
135
- /* ---- left: coverage map (signature) ---- */
 
 
 
 
 
 
 
 
 
 
 
 
136
  .legend { display: flex; gap: 14px; margin-bottom: 18px; }
137
  .legend span { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--ink-soft);
138
  font-family:'IBM Plex Mono',monospace; letter-spacing: .04em; }
@@ -155,7 +167,7 @@
155
  .seg.pulse { animation: pulse .6s ease; }
156
  @keyframes pulse { 0%{transform:scaleY(1)} 40%{transform:scaleY(2.1)} 100%{transform:scaleY(1)} }
157
 
158
- /* ---- right: dialogue ---- */
159
  .dialogue { display: flex; flex-direction: column; height: 100%; min-height: 0; overflow: hidden; }
160
  .transcript { flex: 1; min-height: 0; overflow-y: auto; padding: 22px 22px 8px; }
161
  .empty {
@@ -203,7 +215,9 @@
203
  .badge.Surface { background: var(--surface); color: #1f3a47; }
204
  .badge.Mechanistic { background: var(--mech); }
205
  .badge.Critical { background: var(--crit); }
 
206
  .concept-tag { color: var(--ink-soft); font-style: normal; }
 
207
 
208
  .thinking { font-style: italic; color: var(--ink-soft); font-size: 14px; }
209
  .dot-flash::after { content: "…"; animation: dots 1.2s steps(4,end) infinite; }
@@ -251,14 +265,14 @@
251
  </header>
252
 
253
  <div class="workspace">
254
- <!-- LEFT -->
255
  <section class="panel left">
256
  <div class="panel-head">
257
  <span class="panel-label" id="leftLabel">Material</span>
258
  <span class="panel-meta" id="leftMeta"></span>
259
  </div>
260
  <div class="panel-body" id="leftBody">
261
- <!-- setup state -->
262
  <div id="setup">
263
  <div class="dropzone" id="dropzone">
264
  <div class="big">Drop your lecture material</div>
@@ -272,8 +286,16 @@
272
  <div class="errline hidden" id="setupErr"></div>
273
  </div>
274
 
275
- <!-- coverage state -->
276
  <div id="coverage" class="hidden">
 
 
 
 
 
 
 
 
277
  <div class="legend">
278
  <span><i class="chip s"></i>Surface</span>
279
  <span><i class="chip m"></i>Mechanistic</span>
@@ -284,7 +306,7 @@
284
  </div>
285
  </section>
286
 
287
- <!-- RIGHT -->
288
  <section class="panel right">
289
  <div class="panel-head">
290
  <span class="panel-label">The interrogation</span>
@@ -314,7 +336,7 @@ let provider = "deepseek";
314
 
315
  const $ = (id) => document.getElementById(id);
316
 
317
- // ---- provider toggle ----
318
  document.querySelectorAll("#providerToggle button").forEach((btn) => {
319
  btn.addEventListener("click", () => {
320
  document.querySelectorAll("#providerToggle button").forEach((b) =>
@@ -324,7 +346,7 @@ document.querySelectorAll("#providerToggle button").forEach((btn) => {
324
  });
325
  });
326
 
327
- // ---- material loading ----
328
  $("dropzone").addEventListener("click", () => $("fileInput").click());
329
  $("fileInput").addEventListener("change", (e) => {
330
  selectedFile = e.target.files[0] || null;
@@ -379,7 +401,7 @@ function showSetupErr(msg) {
379
  $("setupErr").classList.remove("hidden");
380
  }
381
 
382
- // ---- enter interrogation ----
383
  function enterInterrogation(data) {
384
  setLoading(false);
385
  $("setup").classList.add("hidden");
@@ -408,6 +430,20 @@ function renderConcepts(concepts, coverage) {
408
  row.innerHTML = `<div class="concept-name">${c}</div><div class="depth">${segs}</div>`;
409
  list.appendChild(row);
410
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
411
  }
412
 
413
  function updateCoverage(coverage) {
@@ -420,9 +456,13 @@ function updateCoverage(coverage) {
420
  setTimeout(() => seg.classList.remove("pulse"), 600);
421
  }
422
  });
 
 
 
 
423
  }
424
 
425
- // ---- asking ----
426
  $("askBtn").addEventListener("click", ask);
427
  $("qInput").addEventListener("keydown", (e) => {
428
  if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); ask(); }
@@ -471,15 +511,29 @@ function addStudentTurn(text) {
471
  lastStudentEl = el;
472
  scrollDown();
473
  }
 
 
474
  function tagLastStudent(level, conf, concepts) {
475
  if (!lastStudentEl) return;
476
  const pct = Math.round(conf * 100);
477
- let conceptBit = "";
 
 
478
  if (concepts && concepts.length > 0) {
479
  conceptBit = ` · <span class="concept-tag">${concepts.join(" + ")}</span>`;
 
 
480
  }
481
- lastStudentEl.querySelector(".who").innerHTML =
482
- `<span class="badge ${level}">${level.toUpperCase()} ${pct}%</span>${conceptBit} You`;
 
 
 
 
 
 
 
 
483
  }
484
  function addThinking() {
485
  const el = document.createElement("div");
@@ -504,10 +558,10 @@ function addExpertTurn(text) {
504
  scrollDown();
505
  }
506
 
507
- // ---- reset ----
508
  $("resetBtn").addEventListener("click", () => location.reload());
509
 
510
- // auto-grow question box
511
  $("qInput").addEventListener("input", function () {
512
  this.style.height = "44px";
513
  this.style.height = Math.min(this.scrollHeight, 120) + "px";
 
35
  }
36
  .mono { font-family: 'IBM Plex Mono', monospace; }
37
 
38
+ /* Header */
39
  header {
40
  display: flex; align-items: baseline; justify-content: space-between;
41
  padding: 22px 32px 18px; color: var(--paper);
 
58
  }
59
  .reset:hover { color: var(--paper); border-color: rgba(237,234,226,.5); }
60
 
61
+ /* Provider toggle */
62
  .provider-pick {
63
  display: flex; align-items: center; gap: 8px;
64
  }
 
75
  .seg-toggle button.active { background: var(--crit); color: #fff; }
76
  .seg-toggle button:not(.active):hover { color: var(--paper); }
77
 
78
+ /* Workspace */
79
  .workspace {
80
  display: grid; grid-template-columns: minmax(300px, 38%) 1fr;
81
  gap: 22px; padding: 22px 32px 32px; height: calc(100vh - 73px);
 
99
  .panel-meta { font-family:'IBM Plex Mono',monospace; font-size: 11px; color: var(--ink-soft); }
100
  .panel-body { flex: 1; overflow-y: auto; padding: 20px; }
101
 
102
+ /* Left: setup */
103
  .dropzone {
104
  border: 1.5px dashed var(--rule-strong); border-radius: 3px;
105
  padding: 28px 18px; text-align: center; cursor: pointer; transition: .15s;
 
132
  .btn-block { width: 100%; margin-top: 14px; }
133
  .filename { font-size: 13px; color: var(--crit-ink); margin-top: 10px; font-weight: 500; }
134
 
135
+ /* Left: coverage map */
136
+ .overall { margin-bottom: 18px; }
137
+ .overall-head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 7px; }
138
+ .overall-label { font-family:'IBM Plex Mono',monospace; font-size: 11px; text-transform: uppercase;
139
+ letter-spacing: .16em; color: var(--ink); }
140
+ .overall-count { font-family:'IBM Plex Mono',monospace; font-size: 11px; color: var(--ink-soft); }
141
+ .overall-track { height: 8px; background: var(--paper-inset); border: 1px solid var(--rule-strong);
142
+ border-radius: 3px; overflow: hidden; }
143
+ .overall-fill { height: 100%; width: 0%;
144
+ background: linear-gradient(90deg, var(--surface), var(--mech) 55%, var(--crit));
145
+ transition: width .5s ease; }
146
+ .overall-sub { font-size: 11px; color: var(--ink-soft); margin-top: 5px; }
147
+
148
  .legend { display: flex; gap: 14px; margin-bottom: 18px; }
149
  .legend span { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--ink-soft);
150
  font-family:'IBM Plex Mono',monospace; letter-spacing: .04em; }
 
167
  .seg.pulse { animation: pulse .6s ease; }
168
  @keyframes pulse { 0%{transform:scaleY(1)} 40%{transform:scaleY(2.1)} 100%{transform:scaleY(1)} }
169
 
170
+ /* Right: dialogue */
171
  .dialogue { display: flex; flex-direction: column; height: 100%; min-height: 0; overflow: hidden; }
172
  .transcript { flex: 1; min-height: 0; overflow-y: auto; padding: 22px 22px 8px; }
173
  .empty {
 
215
  .badge.Surface { background: var(--surface); color: #1f3a47; }
216
  .badge.Mechanistic { background: var(--mech); }
217
  .badge.Critical { background: var(--crit); }
218
+ .badge.uncertain { background: var(--ink-soft); color: var(--paper); opacity: .8; }
219
  .concept-tag { color: var(--ink-soft); font-style: normal; }
220
+ .concept-tag.cross { font-style: italic; opacity: .85; }
221
 
222
  .thinking { font-style: italic; color: var(--ink-soft); font-size: 14px; }
223
  .dot-flash::after { content: "…"; animation: dots 1.2s steps(4,end) infinite; }
 
265
  </header>
266
 
267
  <div class="workspace">
268
+ <!-- Left -->
269
  <section class="panel left">
270
  <div class="panel-head">
271
  <span class="panel-label" id="leftLabel">Material</span>
272
  <span class="panel-meta" id="leftMeta"></span>
273
  </div>
274
  <div class="panel-body" id="leftBody">
275
+ <!-- Setup state -->
276
  <div id="setup">
277
  <div class="dropzone" id="dropzone">
278
  <div class="big">Drop your lecture material</div>
 
286
  <div class="errline hidden" id="setupErr"></div>
287
  </div>
288
 
289
+ <!-- Coverage state -->
290
  <div id="coverage" class="hidden">
291
+ <div class="overall">
292
+ <div class="overall-head">
293
+ <span class="overall-label">Overall depth</span>
294
+ <span class="overall-count" id="overallCount">0 / 0 depth points</span>
295
+ </div>
296
+ <div class="overall-track"><div class="overall-fill" id="overallFill"></div></div>
297
+ <div class="overall-sub">depth reached across all concepts and levels</div>
298
+ </div>
299
  <div class="legend">
300
  <span><i class="chip s"></i>Surface</span>
301
  <span><i class="chip m"></i>Mechanistic</span>
 
306
  </div>
307
  </section>
308
 
309
+ <!-- Right -->
310
  <section class="panel right">
311
  <div class="panel-head">
312
  <span class="panel-label">The interrogation</span>
 
336
 
337
  const $ = (id) => document.getElementById(id);
338
 
339
+ // Provider toggle
340
  document.querySelectorAll("#providerToggle button").forEach((btn) => {
341
  btn.addEventListener("click", () => {
342
  document.querySelectorAll("#providerToggle button").forEach((b) =>
 
346
  });
347
  });
348
 
349
+ // Material loading
350
  $("dropzone").addEventListener("click", () => $("fileInput").click());
351
  $("fileInput").addEventListener("change", (e) => {
352
  selectedFile = e.target.files[0] || null;
 
401
  $("setupErr").classList.remove("hidden");
402
  }
403
 
404
+ // Enter interrogation
405
  function enterInterrogation(data) {
406
  setLoading(false);
407
  $("setup").classList.add("hidden");
 
430
  row.innerHTML = `<div class="concept-name">${c}</div><div class="depth">${segs}</div>`;
431
  list.appendChild(row);
432
  });
433
+ updateOverall(concepts, coverage);
434
+ }
435
+
436
+ // Overall depth = lit / total segments.
437
+ function updateOverall(concepts, coverage) {
438
+ const total = concepts.length * LEVELS.length;
439
+ let lit = 0;
440
+ concepts.forEach((c) => {
441
+ const cov = coverage[c] || {};
442
+ LEVELS.forEach((lv) => { if (cov[lv] > 0) lit += 1; });
443
+ });
444
+ const pct = total > 0 ? Math.round((lit / total) * 100) : 0;
445
+ $("overallFill").style.width = pct + "%";
446
+ $("overallCount").textContent = `${lit} / ${total} depth points`;
447
  }
448
 
449
  function updateCoverage(coverage) {
 
456
  setTimeout(() => seg.classList.remove("pulse"), 600);
457
  }
458
  });
459
+ // Refresh overall depth.
460
+ const concepts = Array.from(document.querySelectorAll(".concept-name"))
461
+ .map((el) => el.textContent);
462
+ updateOverall(concepts, coverage);
463
  }
464
 
465
+ // Asking
466
  $("askBtn").addEventListener("click", ask);
467
  $("qInput").addEventListener("keydown", (e) => {
468
  if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); ask(); }
 
511
  lastStudentEl = el;
512
  scrollDown();
513
  }
514
+ const UNCERTAIN_THRESHOLD = 0.55;
515
+
516
  function tagLastStudent(level, conf, concepts) {
517
  if (!lastStudentEl) return;
518
  const pct = Math.round(conf * 100);
519
+
520
+ // Show concepts or mark as cross-cutting.
521
+ let conceptBit;
522
  if (concepts && concepts.length > 0) {
523
  conceptBit = ` · <span class="concept-tag">${concepts.join(" + ")}</span>`;
524
+ } else {
525
+ conceptBit = ` · <span class="concept-tag cross">cross-cutting</span>`;
526
  }
527
+
528
+ // Mark low-confidence questions.
529
+ let badge;
530
+ if (conf < UNCERTAIN_THRESHOLD) {
531
+ badge = `<span class="badge uncertain">${level.toUpperCase()}? · uncertain</span>`;
532
+ } else {
533
+ badge = `<span class="badge ${level}">${level.toUpperCase()} ${pct}%</span>`;
534
+ }
535
+
536
+ lastStudentEl.querySelector(".who").innerHTML = `${badge}${conceptBit} You`;
537
  }
538
  function addThinking() {
539
  const el = document.createElement("div");
 
558
  scrollDown();
559
  }
560
 
561
+ // Reset
562
  $("resetBtn").addEventListener("click", () => location.reload());
563
 
564
+ // Auto-grow question box
565
  $("qInput").addEventListener("input", function () {
566
  this.style.height = "44px";
567
  this.style.height = Math.min(this.scrollHeight, 120) + "px";
main.py CHANGED
@@ -1,10 +1,4 @@
1
- """FastAPI backend for Dialectica.
2
-
3
- Handles material upload, expert Q&A, question classification, and coverage map.
4
-
5
- Run locally:
6
- PYTORCH_ENABLE_MPS_FALLBACK=1 uvicorn main:app
7
- """
8
 
9
  import os
10
  import time
@@ -26,13 +20,13 @@ app = FastAPI(title="Dialectica")
26
 
27
  VALID_PROVIDERS = ("deepseek", "gemini")
28
 
29
- # Abuse protection settings.
30
- DAILY_CALL_CAP = 200 # global expert-call cap per day
31
  PER_IP_PER_MINUTE = 6 # max requests per IP per minute
32
- MAX_QUESTION_CHARS = 2000 # limit very long questions
33
 
34
  # In-memory rate-limit state.
35
- _ip_hits = defaultdict(deque) # ip -> recent request timestamps
36
  _daily = {"day": None, "count": 0}
37
 
38
  SESSION = {
@@ -46,7 +40,7 @@ COMPONENTS = {"classifier": None, "matcher": None, "experts": {}}
46
 
47
 
48
  def get_classifier_and_matcher():
49
- """Initialize classifier and matcher on first use."""
50
  product_config = config.ProductConfig()
51
  if COMPONENTS["classifier"] is None:
52
  COMPONENTS["classifier"] = CognitiveClassifier(product_config)
@@ -56,7 +50,7 @@ def get_classifier_and_matcher():
56
 
57
 
58
  def get_expert(provider):
59
- """Return cached expert for provider, creating it if needed."""
60
  if provider not in VALID_PROVIDERS:
61
  provider = "deepseek"
62
  if provider not in COMPONENTS["experts"]:
@@ -72,12 +66,12 @@ def get_expert(provider):
72
 
73
 
74
  def _blank_coverage(concepts):
75
- """Create empty coverage counts for each concept."""
76
  return {c: {level: 0 for level in ORDERED_LABELS} for c in concepts}
77
 
78
 
79
  def _check_rate_limit(request):
80
- """Check per-IP per-minute limit."""
81
  client_ip = request.client.host if request.client else "unknown"
82
  now = time.time()
83
  hits = _ip_hits[client_ip]
@@ -90,7 +84,7 @@ def _check_rate_limit(request):
90
 
91
 
92
  def _check_daily_cap():
93
- """Check global daily call cap."""
94
  today = time.strftime("%Y-%m-%d", time.gmtime())
95
  if _daily["day"] != today:
96
  _daily["day"] = today
@@ -102,7 +96,7 @@ def _check_daily_cap():
102
 
103
 
104
  class AskRequest(BaseModel):
105
- """Body for the /api/ask endpoint."""
106
 
107
  question: str
108
  provider: str = "deepseek"
@@ -110,7 +104,7 @@ class AskRequest(BaseModel):
110
 
111
  @app.get("/")
112
  def index():
113
- """Serve the single-page frontend."""
114
  return FileResponse(os.path.join("frontend", "index.html"))
115
 
116
 
@@ -121,7 +115,7 @@ async def upload(
121
  text: str = Form(None),
122
  provider: str = Form("deepseek"),
123
  ):
124
- """Parse uploaded material or pasted text and extract concepts."""
125
  rate_error = _check_rate_limit(request)
126
  if rate_error:
127
  return JSONResponse(status_code=429, content={"error": rate_error})
@@ -157,7 +151,7 @@ async def upload(
157
 
158
  concepts = expert.extract_concepts(material)
159
  matcher.set_concepts(concepts)
160
- _daily["count"] += 1 # concept extraction uses one expert call
161
 
162
  SESSION["material"] = material
163
  SESSION["concepts"] = concepts
@@ -170,7 +164,7 @@ async def upload(
170
 
171
  @app.post("/api/ask")
172
  def ask(request: Request, body: AskRequest):
173
- """Classify the question, answer as the expert, and update coverage."""
174
  rate_error = _check_rate_limit(request)
175
  if rate_error:
176
  return JSONResponse(status_code=429, content={"error": rate_error})
@@ -201,7 +195,7 @@ def ask(request: Request, body: AskRequest):
201
 
202
  classification = classifier.classify(question)
203
  level = classification["level"]
204
- concepts = matcher.match(question) # list (can be empty)
205
 
206
  answer_text = expert.answer(question, SESSION["material"], SESSION["history"])
207
  _daily["count"] += 1
@@ -225,5 +219,5 @@ def ask(request: Request, body: AskRequest):
225
 
226
  @app.get("/api/coverage")
227
  def coverage():
228
- """Return the current coverage map and concept list."""
229
  return {"concepts": SESSION["concepts"], "coverage": SESSION["coverage"]}
 
1
+ """FastAPI backend for Dialectica."""
 
 
 
 
 
 
2
 
3
  import os
4
  import time
 
20
 
21
  VALID_PROVIDERS = ("deepseek", "gemini")
22
 
23
+ # Basic limits.
24
+ DAILY_CALL_CAP = 200 # expert-call cap per day
25
  PER_IP_PER_MINUTE = 6 # max requests per IP per minute
26
+ MAX_QUESTION_CHARS = 2000 # max question length
27
 
28
  # In-memory rate-limit state.
29
+ _ip_hits = defaultdict(deque) # ip -> recent timestamps
30
  _daily = {"day": None, "count": 0}
31
 
32
  SESSION = {
 
40
 
41
 
42
  def get_classifier_and_matcher():
43
+ """Get classifier and matcher (lazy init)."""
44
  product_config = config.ProductConfig()
45
  if COMPONENTS["classifier"] is None:
46
  COMPONENTS["classifier"] = CognitiveClassifier(product_config)
 
50
 
51
 
52
  def get_expert(provider):
53
+ """Get cached expert for provider."""
54
  if provider not in VALID_PROVIDERS:
55
  provider = "deepseek"
56
  if provider not in COMPONENTS["experts"]:
 
66
 
67
 
68
  def _blank_coverage(concepts):
69
+ """Make empty coverage map."""
70
  return {c: {level: 0 for level in ORDERED_LABELS} for c in concepts}
71
 
72
 
73
  def _check_rate_limit(request):
74
+ """Check per-IP rate limit."""
75
  client_ip = request.client.host if request.client else "unknown"
76
  now = time.time()
77
  hits = _ip_hits[client_ip]
 
84
 
85
 
86
  def _check_daily_cap():
87
+ """Check daily usage cap."""
88
  today = time.strftime("%Y-%m-%d", time.gmtime())
89
  if _daily["day"] != today:
90
  _daily["day"] = today
 
96
 
97
 
98
  class AskRequest(BaseModel):
99
+ """Request body for /api/ask."""
100
 
101
  question: str
102
  provider: str = "deepseek"
 
104
 
105
  @app.get("/")
106
  def index():
107
+ """Serve frontend page."""
108
  return FileResponse(os.path.join("frontend", "index.html"))
109
 
110
 
 
115
  text: str = Form(None),
116
  provider: str = Form("deepseek"),
117
  ):
118
+ """Load material and extract concepts."""
119
  rate_error = _check_rate_limit(request)
120
  if rate_error:
121
  return JSONResponse(status_code=429, content={"error": rate_error})
 
151
 
152
  concepts = expert.extract_concepts(material)
153
  matcher.set_concepts(concepts)
154
+ _daily["count"] += 1 # concept extraction counts as one call
155
 
156
  SESSION["material"] = material
157
  SESSION["concepts"] = concepts
 
164
 
165
  @app.post("/api/ask")
166
  def ask(request: Request, body: AskRequest):
167
+ """Answer one question and update coverage."""
168
  rate_error = _check_rate_limit(request)
169
  if rate_error:
170
  return JSONResponse(status_code=429, content={"error": rate_error})
 
195
 
196
  classification = classifier.classify(question)
197
  level = classification["level"]
198
+ concepts = matcher.match(question) # may be empty
199
 
200
  answer_text = expert.answer(question, SESSION["material"], SESSION["history"])
201
  _daily["count"] += 1
 
219
 
220
  @app.get("/api/coverage")
221
  def coverage():
222
+ """Return current coverage state."""
223
  return {"concepts": SESSION["concepts"], "coverage": SESSION["coverage"]}
scripts/inference.py CHANGED
@@ -1,16 +1,13 @@
1
- """Inference utilities for Dialectica.
2
-
3
- Includes question-level classification and concept matching.
4
- """
5
 
6
  ORDERED_LABELS = ["Surface", "Mechanistic", "Critical"]
7
 
8
 
9
  class CognitiveClassifier:
10
- """Use DistilBERT to classify question cognitive level."""
11
 
12
  def __init__(self, product_config):
13
- """Load classifier and tokenizer."""
14
  import torch
15
  from transformers import (
16
  AutoModelForSequenceClassification,
@@ -24,13 +21,12 @@ class CognitiveClassifier:
24
  self.cfg.classifier_dir
25
  )
26
  self.model.eval()
27
- # Use model labels if available; otherwise use default order.
28
  self.id2label = self.model.config.id2label or {
29
  i: label for i, label in enumerate(ORDERED_LABELS)
30
  }
31
 
32
  def classify(self, question):
33
- """Return the predicted level and a confidence score for one question."""
34
  inputs = self.tokenizer(
35
  question,
36
  truncation=True,
@@ -48,10 +44,10 @@ class CognitiveClassifier:
48
 
49
 
50
  class ConceptMatcher:
51
- """Match a question to nearest concepts by embedding similarity."""
52
 
53
  def __init__(self, product_config):
54
- """Load sentence embedding model."""
55
  from sentence_transformers import SentenceTransformer
56
 
57
  self.cfg = product_config
@@ -60,7 +56,7 @@ class ConceptMatcher:
60
  self.concept_embeddings = None
61
 
62
  def set_concepts(self, concepts):
63
- """Store concepts and precompute embeddings."""
64
  self.concepts = concepts
65
  if concepts:
66
  self.concept_embeddings = self.model.encode(
@@ -70,7 +66,7 @@ class ConceptMatcher:
70
  self.concept_embeddings = None
71
 
72
  def match(self, question):
73
- """Return up to two matched concepts, or an empty list."""
74
  from sentence_transformers import util
75
 
76
  if not self.concepts or self.concept_embeddings is None:
@@ -84,7 +80,9 @@ class ConceptMatcher:
84
  reverse=True,
85
  )
86
  threshold = self.cfg.concept_match_threshold
87
- runner_up_margin = 0.07 # allow a close second concept
 
 
88
 
89
  top_index = ranked[0]
90
  top_score = float(scores[top_index])
@@ -95,7 +93,8 @@ class ConceptMatcher:
95
  if len(ranked) > 1:
96
  second_index = ranked[1]
97
  second_score = float(scores[second_index])
98
- if (second_score >= threshold
99
- and top_score - second_score <= runner_up_margin):
 
100
  matched.append(self.concepts[second_index])
101
  return matched
 
1
+ """Inference helpers for Dialectica."""
 
 
 
2
 
3
  ORDERED_LABELS = ["Surface", "Mechanistic", "Critical"]
4
 
5
 
6
  class CognitiveClassifier:
7
+ """DistilBERT question classifier."""
8
 
9
  def __init__(self, product_config):
10
+ """Load model and tokenizer."""
11
  import torch
12
  from transformers import (
13
  AutoModelForSequenceClassification,
 
21
  self.cfg.classifier_dir
22
  )
23
  self.model.eval()
 
24
  self.id2label = self.model.config.id2label or {
25
  i: label for i, label in enumerate(ORDERED_LABELS)
26
  }
27
 
28
  def classify(self, question):
29
+ """Predict level and confidence."""
30
  inputs = self.tokenizer(
31
  question,
32
  truncation=True,
 
44
 
45
 
46
  class ConceptMatcher:
47
+ """Embedding-based concept matcher."""
48
 
49
  def __init__(self, product_config):
50
+ """Load embedding model."""
51
  from sentence_transformers import SentenceTransformer
52
 
53
  self.cfg = product_config
 
56
  self.concept_embeddings = None
57
 
58
  def set_concepts(self, concepts):
59
+ """Cache concept embeddings."""
60
  self.concepts = concepts
61
  if concepts:
62
  self.concept_embeddings = self.model.encode(
 
66
  self.concept_embeddings = None
67
 
68
  def match(self, question):
69
+ """Return up to two matching concepts."""
70
  from sentence_transformers import util
71
 
72
  if not self.concepts or self.concept_embeddings is None:
 
80
  reverse=True,
81
  )
82
  threshold = self.cfg.concept_match_threshold
83
+ # Keep a second concept if it's close to top or strong enough.
84
+ runner_up_margin = 0.05
85
+ runner_up_absolute = 0.45
86
 
87
  top_index = ranked[0]
88
  top_score = float(scores[top_index])
 
93
  if len(ranked) > 1:
94
  second_index = ranked[1]
95
  second_score = float(scores[second_index])
96
+ close_to_top = top_score - second_score <= runner_up_margin
97
+ strong_alone = second_score >= runner_up_absolute
98
+ if second_score >= threshold and (close_to_top or strong_alone):
99
  matched.append(self.concepts[second_index])
100
  return matched