Adi362 commited on
Commit
b6aa2c9
·
verified ·
1 Parent(s): 0287634

Upload 5 files

Browse files
Files changed (5) hide show
  1. Dockerfile +28 -0
  2. README.md +57 -11
  3. main.py +744 -0
  4. pyproject.toml +13 -0
  5. requirements.txt +5 -0
Dockerfile ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+
6
+
7
+ ENV PYTHONDONTWRITEBYTECODE=1
8
+
9
+ ENV PYTHONUNBUFFERED=1
10
+
11
+
12
+
13
+ COPY requirements.txt ./
14
+
15
+ RUN pip install --no-cache-dir -r requirements.txt
16
+
17
+
18
+
19
+ COPY . .
20
+
21
+
22
+
23
+ EXPOSE 7860
24
+
25
+
26
+
27
+ CMD ["sh", "-c", "uvicorn main:app --host 0.0.0.0 --port ${PORT:-7860}"]
28
+
README.md CHANGED
@@ -1,11 +1,57 @@
1
- ---
2
- title: Cortexflow
3
- emoji: 🌍
4
- colorFrom: blue
5
- colorTo: yellow
6
- sdk: docker
7
- pinned: false
8
- short_description: backend for cortexflow
9
- ---
10
-
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Backend
2
+
3
+ FastAPI backend service for CortexFlow.
4
+
5
+ Joint collaboration repository by the MA2TIC group.
6
+
7
+ Private codebase. Licensing context is defined in [../LICENSE](../LICENSE).
8
+
9
+ ## Design Profile
10
+
11
+ - Deterministic feature extraction from transcript and pause signals
12
+ - Confidence and quality notes for low-evidence samples
13
+ - Non-diagnostic safety framing in generated output
14
+ - Groq model usage limited to narrative/safety language, not synthetic metric creation
15
+
16
+ ## API Endpoints
17
+
18
+ - `GET /health`
19
+ - `GET /models/recommended`
20
+ - `POST /analyze`
21
+
22
+ `POST /analyze` payload shape:
23
+
24
+ ```json
25
+ {
26
+ "input_value": "optional text input",
27
+ "transcript": "optional transcript input",
28
+ "pause_map": [0.32, 0.45],
29
+ "audio_duration": 24.8,
30
+ "session_id": "optional"
31
+ }
32
+ ```
33
+
34
+ Response format: streamed NDJSON with step and final events.
35
+
36
+ ## Hugging Face Spaces (Docker)
37
+
38
+ Hosting target: Docker Space.
39
+
40
+ Deployment profile:
41
+
42
+ - Repository root in Space contains backend files (`main.py`, `requirements.txt`, `Dockerfile`, supporting modules)
43
+ - Required secret: `GROQ_API_KEY`
44
+ - Container runtime binds to `${PORT}` (default fallback configured in Dockerfile)
45
+ - Health probe endpoint: `GET /health`
46
+
47
+ ## Local Development Reference
48
+
49
+ ```bash
50
+ cd backend
51
+ python -m venv .venv
52
+ . .venv/Scripts/Activate.ps1
53
+ pip install -r requirements.txt
54
+ copy .env.example .env
55
+ uvicorn main:app --reload --port 8000
56
+ ```
57
+
main.py ADDED
@@ -0,0 +1,744 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import json
3
+ import os
4
+ import re
5
+ import statistics
6
+ import time
7
+ import uuid
8
+ from dataclasses import dataclass
9
+ from typing import Any, Optional
10
+ import httpx
11
+ from dotenv import load_dotenv
12
+ from fastapi import FastAPI, HTTPException
13
+ from fastapi.middleware.cors import CORSMiddleware
14
+ from fastapi.responses import StreamingResponse
15
+ from pydantic import BaseModel, Field
16
+
17
+ load_dotenv()
18
+
19
+ app = FastAPI(title="CortexFlow Backend", version="1.0.0")
20
+ app.add_middleware(
21
+ CORSMiddleware,
22
+ allow_origins=["*"],
23
+ allow_methods=["GET", "POST"],
24
+ allow_headers=["*"],
25
+ )
26
+
27
+
28
+ GROQ_API_KEY = os.getenv("GROQ_API_KEY", "").strip()
29
+ GROQ_API_BASE = os.getenv("GROQ_API_BASE", "https://api.groq.com/openai/v1").rstrip("/")
30
+ GROQ_TIMEOUT_SECONDS = float(os.getenv("GROQ_TIMEOUT_SECONDS", "40"))
31
+ MODEL_DISCOVERY_TTL_SECONDS = int(os.getenv("MODEL_DISCOVERY_TTL_SECONDS", "900"))
32
+
33
+ PREFERRED_REASONING_MODELS = [
34
+ m.strip()
35
+ for m in os.getenv(
36
+ "GROQ_REASONING_CANDIDATES",
37
+ "openai/gpt-oss-120b,llama-3.3-70b-versatile,openai/gpt-oss-20b,llama-3.1-8b-instant",
38
+ ).split(",")
39
+ if m.strip()
40
+ ]
41
+ PREFERRED_SAFETY_MODELS = [
42
+ m.strip()
43
+ for m in os.getenv(
44
+ "GROQ_SAFETY_CANDIDATES",
45
+ "openai/gpt-oss-safeguard-20b,openai/gpt-oss-20b,llama-3.1-8b-instant",
46
+ ).split(",")
47
+ if m.strip()
48
+ ]
49
+
50
+ OVERRIDE_REASONING_MODEL = os.getenv("GROQ_REASONING_MODEL", "").strip()
51
+ OVERRIDE_SAFETY_MODEL = os.getenv("GROQ_SAFETY_MODEL", "").strip()
52
+
53
+ MIN_WORDS_REQUIRED = int(os.getenv("MIN_WORDS_REQUIRED", "25"))
54
+
55
+ STEP_NAMES = [
56
+ "STT preprocessor",
57
+ "Lexical agent",
58
+ "Semantic agent",
59
+ "Prosody agent",
60
+ "Syntax agent",
61
+ "Biomarker mapper",
62
+ "Report composer",
63
+ ]
64
+ DOMAIN_REGION = {
65
+ "lexical": "Broca's area",
66
+ "semantic": "Wernicke's area",
67
+ "prosody": "SMA",
68
+ "syntax": "DLPFC",
69
+ "affective": "Amygdala",
70
+ }
71
+
72
+ STOPWORDS = {
73
+ "the", "a", "an", "and", "or", "but", "if", "then", "than", "of", "to", "in", "on", "at", "for",
74
+ "with", "without", "by", "from", "as", "is", "am", "are", "was", "were", "be", "been", "being",
75
+ "it", "its", "this", "that", "these", "those", "i", "you", "he", "she", "we", "they", "them",
76
+ "my", "your", "our", "their", "me", "him", "her", "us", "do", "does", "did", "have", "has", "had",
77
+ "not", "no", "yes", "so", "because", "about", "into", "out", "up", "down", "can", "could", "would",
78
+ "should", "will", "just", "very", "really", "also",
79
+ }
80
+
81
+ FILLERS = {
82
+ "um", "uh", "erm", "hmm", "like", "you", "know", "actually", "basically", "literally", "sort", "kind", "maybe",
83
+ }
84
+
85
+ POSITIVE_WORDS = {
86
+ "good", "better", "great", "calm", "confident", "clear", "focused", "stable", "happy", "optimistic", "safe", "steady",
87
+ }
88
+ NEGATIVE_WORDS = {
89
+ "bad", "worse", "anxious", "scared", "panic", "panicked", "confused", "sad", "depressed", "angry", "overwhelmed", "stressed",
90
+ }
91
+ AROUSAL_WORDS = {
92
+ "urgent", "immediately", "intense", "extreme", "critical", "afraid", "panic", "terrified", "racing", "shaking", "worried",
93
+ }
94
+ HEDGE_WORDS = {
95
+ "maybe", "perhaps", "possibly", "probably", "sort", "kind", "might", "could", "guess", "unsure", "not sure",
96
+ }
97
+ SUBORDINATORS = {
98
+ "because", "although", "though", "while", "unless", "until", "since", "whereas", "however", "therefore", "moreover", "which", "that",
99
+ }
100
+
101
+
102
+ class AnalyzeRequest(BaseModel):
103
+ input_value: Optional[str] = None
104
+ transcript: Optional[str] = None
105
+ pause_map: Optional[list[float]] = None
106
+ audio_duration: Optional[float] = None
107
+ session_id: Optional[str] = None
108
+
109
+
110
+ @dataclass
111
+ class DomainScore:
112
+ overall: float
113
+ details: dict[str, float]
114
+
115
+ @dataclass
116
+ class AnalysisState:
117
+ scores: dict[str, DomainScore]
118
+ overall_load: float
119
+ confidence: float
120
+ quality_notes: list[str]
121
+ metrics: dict[str, Any]
122
+
123
+
124
+ _MODEL_CACHE: dict[str, Any] = {"updated": 0.0, "models": []}
125
+ _MODEL_CACHE_LOCK = asyncio.Lock()
126
+
127
+ def clamp01(v: float) -> float:
128
+ return max(0.0, min(1.0, v))
129
+
130
+
131
+ def mean(values: list[float], default: float = 0.0) -> float:
132
+ return float(statistics.mean(values)) if values else default
133
+
134
+
135
+ def tokenize_words(text: str) -> list[str]:
136
+ return re.findall(r"[A-Za-z']+", text.lower())
137
+
138
+
139
+ def split_sentences(text: str) -> list[str]:
140
+ parts = [p.strip() for p in re.split(r"(?<=[.!?])\s+", text) if p.strip()]
141
+ return parts if parts else ([text.strip()] if text.strip() else [])
142
+
143
+
144
+ def content_words(tokens: list[str]) -> list[str]:
145
+ return [t for t in tokens if len(t) > 2 and t not in STOPWORDS]
146
+
147
+
148
+ def jaccard(a: set[str], b: set[str]) -> float:
149
+ if not a or not b:
150
+ return 0.0
151
+ inter = len(a.intersection(b))
152
+ union = len(a.union(b))
153
+ return inter / union if union else 0.0
154
+
155
+
156
+ def scale_linear(value: float, low: float, high: float) -> float:
157
+ if high <= low:
158
+ return 0.0
159
+ return clamp01((value - low) / (high - low))
160
+
161
+
162
+ def scale_inverse(value: float, good: float, poor: float) -> float:
163
+ if poor >= good:
164
+ return 0.0
165
+ return clamp01((good - value) / (good - poor))
166
+
167
+ def safe_step_event(name: str, status: str, detail: Optional[str] = None) -> bytes:
168
+ payload: dict[str, Any] = {"type": "step", "step": {"name": name, "status": status}}
169
+ if detail:
170
+ payload["step"]["detail"] = detail
171
+ return (json.dumps(payload) + "\n").encode()
172
+
173
+
174
+ def ensure_nonempty_text(req: AnalyzeRequest) -> str:
175
+ text = (req.input_value or req.transcript or "").strip()
176
+ words = tokenize_words(text)
177
+ if not text:
178
+ raise HTTPException(status_code=400, detail="No input text provided")
179
+ if len(words) < MIN_WORDS_REQUIRED:
180
+ raise HTTPException(
181
+ status_code=422,
182
+ detail=f"Need at least {MIN_WORDS_REQUIRED} words for reliable analysis. Received {len(words)} words.",
183
+ )
184
+ return text
185
+
186
+
187
+ def lexical_domain(tokens: list[str], content: list[str]) -> tuple[DomainScore, dict[str, float]]:
188
+ total = max(len(tokens), 1)
189
+ unique = len(set(tokens))
190
+ filler_hits = sum(1 for t in tokens if t in FILLERS)
191
+
192
+ ttr = unique / total
193
+ density = len(content) / total
194
+ filler_rate = (filler_hits / total) * 100.0
195
+
196
+ s_ttr = clamp01(abs(ttr - 0.52) / 0.30)
197
+ s_density = clamp01(abs(density - 0.58) / 0.25)
198
+ s_filler = scale_linear(filler_rate, 2.0, 14.0)
199
+
200
+ overall = clamp01((0.4 * s_ttr) + (0.35 * s_density) + (0.25 * s_filler))
201
+
202
+ details = {
203
+ "ttr": round(s_ttr, 4),
204
+ "density": round(s_density, 4),
205
+ "filler_rate": round(s_filler, 4),
206
+ }
207
+ raw = {
208
+ "ttr": round(ttr, 4),
209
+ "lexical_density": round(density, 4),
210
+ "filler_rate_per_100w": round(filler_rate, 2),
211
+ }
212
+ return DomainScore(round(overall, 4), details), raw
213
+
214
+
215
+ def semantic_domain(sentences: list[str]) -> tuple[DomainScore, dict[str, float]]:
216
+ if len(sentences) < 2:
217
+ coherence = 0.16
218
+ idea_density = 0.45
219
+ tangentiality = 0.55
220
+ else:
221
+ sentence_content = [set(content_words(tokenize_words(s))) for s in sentences]
222
+ pairwise = [jaccard(sentence_content[i], sentence_content[i + 1]) for i in range(len(sentence_content) - 1)]
223
+ coherence = mean(pairwise, default=0.12)
224
+ avg_content_len = mean([len(x) for x in sentence_content], default=0.0)
225
+ idea_density = clamp01(avg_content_len / 14.0)
226
+ tangentiality = clamp01(1.0 - coherence)
227
+ s_coherence = scale_inverse(coherence, good=0.22, poor=0.05)
228
+ s_idea_density = scale_inverse(idea_density, good=0.65, poor=0.25)
229
+ s_tangentiality = scale_linear(tangentiality, low=0.35, high=0.85)
230
+
231
+ overall = clamp01((0.45 * s_coherence) + (0.30 * s_idea_density) + (0.25 * s_tangentiality))
232
+
233
+ details = {
234
+ "coherence": round(s_coherence, 4),
235
+ "idea_density": round(s_idea_density, 4),
236
+ "tangentiality": round(s_tangentiality, 4),
237
+ }
238
+ raw = {
239
+ "coherence_index": round(coherence, 4),
240
+ "idea_density_index": round(idea_density, 4),
241
+ "tangentiality_index": round(tangentiality, 4),
242
+ }
243
+ return DomainScore(round(overall, 4), details), raw
244
+
245
+
246
+ def prosody_domain(
247
+ tokens: list[str], text: str, pause_map: Optional[list[float]], audio_duration: Optional[float]
248
+ ) -> tuple[DomainScore, dict[str, float], bool]:
249
+ word_count = max(len(tokens), 1)
250
+ pauses = [float(p) for p in (pause_map or []) if p >= 0]
251
+ has_audio_prosody = bool(pauses)
252
+
253
+ if audio_duration and audio_duration > 5.0:
254
+ duration_seconds = audio_duration
255
+ else:
256
+ estimated_speech_seconds = word_count / 2.5
257
+ duration_seconds = estimated_speech_seconds + sum(pauses)
258
+
259
+ duration_minutes = max(duration_seconds / 60.0, 0.1)
260
+ speech_rate = word_count / duration_minutes
261
+
262
+ if pauses:
263
+ pause_freq = len(pauses) / duration_minutes
264
+ hesitation_ratio = sum(1 for p in pauses if p >= 0.8) / len(pauses)
265
+ else:
266
+ punctuation_pauses = len(re.findall(r"[,;:\-]", text))
267
+ pause_freq = (punctuation_pauses / max(word_count, 1)) * 100
268
+ hesitation_ratio = sum(1 for t in tokens if t in FILLERS) / max(word_count, 1)
269
+
270
+ s_rate = clamp01(abs(speech_rate - 140.0) / 95.0)
271
+ s_pause = scale_linear(pause_freq, low=8.0, high=30.0)
272
+ s_hes = scale_linear(hesitation_ratio, low=0.08, high=0.35)
273
+
274
+ overall = clamp01((0.4 * s_rate) + (0.35 * s_pause) + (0.25 * s_hes))
275
+
276
+ details = {
277
+ "speech_rate": round(s_rate, 4),
278
+ "pause_freq": round(s_pause, 4),
279
+ "hesitation": round(s_hes, 4),
280
+ }
281
+ raw = {
282
+ "speech_rate_wpm": round(speech_rate, 1),
283
+ "pause_frequency_per_min": round(pause_freq, 2),
284
+ "hesitation_ratio": round(hesitation_ratio, 4),
285
+ "duration_seconds": round(duration_seconds, 2),
286
+ }
287
+ return DomainScore(round(overall, 4), details), raw, has_audio_prosody
288
+
289
+ def syntax_domain(tokens: list[str], sentences: list[str], text: str) -> tuple[DomainScore, dict[str, float]]:
290
+ sentence_count = max(len(sentences), 1)
291
+ mlu = len(tokens) / sentence_count
292
+
293
+ per_sentence_depth = []
294
+ for s in sentences:
295
+ stoks = tokenize_words(s)
296
+ sub_count = sum(1 for t in stoks if t in SUBORDINATORS)
297
+ comma_count = s.count(",")
298
+ per_sentence_depth.append(sub_count + (comma_count * 0.5))
299
+ clause_depth = mean(per_sentence_depth, default=0.0)
300
+
301
+ passive_matches = re.findall(r"\b(?:is|are|was|were|be|been|being)\s+\w+(?:ed|en)\b", text.lower())
302
+ passive_ratio = len(passive_matches) / max(sentence_count, 1)
303
+
304
+ s_mlu = clamp01(abs(mlu - 17.0) / 12.0)
305
+ s_depth = scale_linear(clause_depth, low=2.0, high=6.5)
306
+ s_passive = scale_linear(passive_ratio, low=0.15, high=1.2)
307
+
308
+ overall = clamp01((0.45 * s_mlu) + (0.35 * s_depth) + (0.20 * s_passive))
309
+
310
+ details = {
311
+ "mlu": round(s_mlu, 4),
312
+ "clause_depth": round(s_depth, 4),
313
+ "passive_ratio": round(s_passive, 4),
314
+ }
315
+ raw = {
316
+ "mean_length_utterance": round(mlu, 2),
317
+ "clause_depth_index": round(clause_depth, 2),
318
+ "passive_ratio": round(passive_ratio, 3),
319
+ }
320
+ return DomainScore(round(overall, 4), details), raw
321
+
322
+
323
+ def affective_domain(tokens: list[str]) -> tuple[DomainScore, dict[str, float]]:
324
+ total = max(len(tokens), 1)
325
+ pos = sum(1 for t in tokens if t in POSITIVE_WORDS)
326
+ neg = sum(1 for t in tokens if t in NEGATIVE_WORDS)
327
+ arousal = sum(1 for t in tokens if t in AROUSAL_WORDS)
328
+ hedge = sum(1 for t in tokens if t in HEDGE_WORDS)
329
+
330
+ valence = (pos - neg) / (pos + neg + 1)
331
+ valence_01 = (valence + 1.0) / 2.0
332
+ arousal_rate = (arousal / total) * 100.0
333
+ certainty = 1.0 - clamp01(hedge / max(total * 0.15, 1.0))
334
+
335
+ s_valence = scale_inverse(valence_01, good=0.62, poor=0.20)
336
+ s_arousal = scale_linear(arousal_rate, low=3.0, high=14.0)
337
+ s_certainty = scale_inverse(certainty, good=0.72, poor=0.32)
338
+ overall = clamp01((0.4 * s_valence) + (0.35 * s_arousal) + (0.25 * s_certainty))
339
+
340
+ details = {
341
+ "valence": round(s_valence, 4),
342
+ "arousal": round(s_arousal, 4),
343
+ "certainty": round(s_certainty, 4),
344
+ }
345
+ raw = {
346
+ "valence_score": round(valence_01, 4),
347
+ "arousal_rate_per_100w": round(arousal_rate, 2),
348
+ "certainty_index": round(certainty, 4),
349
+ }
350
+ return DomainScore(round(overall, 4), details), raw
351
+
352
+
353
+ def compute_confidence(
354
+ word_count: int, sentence_count: int, has_audio_prosody: bool, repeat_ratio: float
355
+ ) -> tuple[float, list[str]]:
356
+ notes: list[str] = []
357
+ c_words = clamp01(word_count / 180.0)
358
+ c_sents = clamp01(sentence_count / 8.0)
359
+ c_repeat = clamp01(1.0 - (repeat_ratio * 1.4))
360
+ c_audio = 1.0 if has_audio_prosody else 0.55
361
+
362
+ confidence = clamp01((0.45 * c_words) + (0.2 * c_sents) + (0.2 * c_repeat) + (0.15 * c_audio))
363
+
364
+ if word_count < 60:
365
+ notes.append("Low sample length. Interpret results cautiously.")
366
+ if not has_audio_prosody:
367
+ notes.append("Prosody is inferred from text patterns because pause-map audio features were not provided.")
368
+ if repeat_ratio > 0.45:
369
+ notes.append("High repetition detected, which can reduce semantic reliability.")
370
+
371
+ return round(confidence, 4), notes
372
+
373
+
374
+ def compute_analysis_state(
375
+ text: str,
376
+ pause_map: Optional[list[float]],
377
+ audio_duration: Optional[float],
378
+ ) -> AnalysisState:
379
+ tokens = tokenize_words(text)
380
+ sentences = split_sentences(text)
381
+ cwords = content_words(tokens)
382
+
383
+ repeat_ratio = 1.0 - (len(set(tokens)) / max(len(tokens), 1))
384
+
385
+ lexical, lexical_raw = lexical_domain(tokens, cwords)
386
+ semantic, semantic_raw = semantic_domain(sentences)
387
+ prosody, prosody_raw, has_audio = prosody_domain(tokens, text, pause_map, audio_duration)
388
+ syntax, syntax_raw = syntax_domain(tokens, sentences, text)
389
+ affective, affective_raw = affective_domain(tokens)
390
+ confidence, quality_notes = compute_confidence(
391
+ word_count=len(tokens),
392
+ sentence_count=len(sentences),
393
+ has_audio_prosody=has_audio,
394
+ repeat_ratio=repeat_ratio,
395
+ )
396
+
397
+ scores = {
398
+ "lexical": lexical,
399
+ "semantic": semantic,
400
+ "prosody": prosody,
401
+ "syntax": syntax,
402
+ "affective": affective,
403
+ }
404
+
405
+ weighted = (
406
+ (0.22 * lexical.overall)
407
+ + (0.23 * semantic.overall)
408
+ + (0.18 * prosody.overall)
409
+ + (0.22 * syntax.overall)
410
+ + (0.15 * affective.overall)
411
+ )
412
+
413
+ confidence_factor = 0.75 + (0.25 * confidence)
414
+ overall_load = clamp01(weighted * confidence_factor)
415
+
416
+ metrics = {
417
+ "word_count": len(tokens),
418
+ "sentence_count": len(sentences),
419
+ "repeat_ratio": round(repeat_ratio, 4),
420
+ "lexical": lexical_raw,
421
+ "semantic": semantic_raw,
422
+ "prosody": prosody_raw,
423
+ "syntax": syntax_raw,
424
+ "affective": affective_raw,
425
+ }
426
+
427
+ return AnalysisState(
428
+ scores=scores,
429
+ overall_load=round(overall_load, 4),
430
+ confidence=confidence,
431
+ quality_notes=quality_notes,
432
+ metrics=metrics,
433
+ )
434
+
435
+
436
+ def severity_from_score(value: float) -> str:
437
+ if value >= 0.72:
438
+ return "high"
439
+ if value >= 0.42:
440
+ return "moderate"
441
+ return "low"
442
+
443
+ def level_from_overall(overall_load: float, confidence: float) -> str:
444
+ if overall_load >= 0.68:
445
+ base = "high"
446
+ elif overall_load >= 0.44:
447
+ base = "moderate"
448
+ else:
449
+ base = "low"
450
+
451
+ if confidence < 0.45 and base == "high":
452
+ return "moderate"
453
+ return base
454
+
455
+
456
+ def summary_fallback(state: AnalysisState, risk_level: str) -> str:
457
+ top_domain = max(state.scores.items(), key=lambda kv: kv[1].overall)[0]
458
+ top_value = state.scores[top_domain].overall
459
+ confidence_pct = round(state.confidence * 100)
460
+ return (
461
+ f"This analysis found a {risk_level} overall cognitive load signal based on linguistic and timing features. "
462
+ f"The strongest deviation appeared in {top_domain} markers (score {top_value:.2f}). "
463
+ f"Confidence is {confidence_pct}% and this output is screening support only, not a diagnosis."
464
+ )
465
+
466
+
467
+ def make_highlights(state: AnalysisState) -> list[dict[str, Any]]:
468
+ sorted_domains = sorted(state.scores.items(), key=lambda kv: kv[1].overall, reverse=True)
469
+ highlights: list[dict[str, Any]] = []
470
+ for domain, score in sorted_domains[:3]:
471
+ if score.overall >= 0.66:
472
+ finding = "Elevated deviation from expected baseline in this domain."
473
+ elif score.overall >= 0.42:
474
+ finding = "Mild-to-moderate deviation with mixed stability."
475
+ else:
476
+ finding = "Signals remain within expected variation for this domain."
477
+
478
+ highlights.append(
479
+ {
480
+ "region": DOMAIN_REGION[domain],
481
+ "activation": round(score.overall, 4),
482
+ "finding": finding,
483
+ "clinical_context": "Screening signal only. Interpret alongside clinical judgement and repeated assessments.",
484
+ }
485
+ )
486
+ return highlights
487
+
488
+
489
+ def make_indicators(state: AnalysisState) -> list[dict[str, Any]]:
490
+ indicators: list[dict[str, Any]] = []
491
+ for domain, dscore in state.scores.items():
492
+ for k, v in dscore.details.items():
493
+ if v < 0.42:
494
+ continue
495
+ indicators.append(
496
+ {
497
+ "indicator": f"{domain.title()} · {k.replace('_', ' ').title()}",
498
+ "severity": severity_from_score(v),
499
+ "explanation": f"Computed score {v:.2f} from measured input features; higher means greater deviation from baseline patterns.",
500
+ }
501
+ )
502
+ indicators.sort(key=lambda x: {"high": 2, "moderate": 1, "low": 0}[x["severity"]], reverse=True)
503
+ return indicators[:6]
504
+
505
+
506
+ def recommendation_for_level(level: str, confidence: float) -> str:
507
+ if level == "high":
508
+ return (
509
+ "Repeat this assessment with a longer sample, then discuss the combined results with a qualified clinician. "
510
+ "Do not treat this result as a diagnosis."
511
+ )
512
+ if level == "moderate":
513
+ return (
514
+ "Collect 1-2 additional samples across different times of day to confirm trend stability before drawing conclusions."
515
+ )
516
+ if confidence < 0.5:
517
+ return "Provide a longer speech sample for stronger reliability before interpreting the result."
518
+ return "Current signals are relatively stable. Continue periodic monitoring rather than one-off interpretation."
519
+
520
+
521
+ async def fetch_available_models() -> list[str]:
522
+ if not GROQ_API_KEY:
523
+ return []
524
+
525
+ async with _MODEL_CACHE_LOCK:
526
+ now = time.time()
527
+ if now - float(_MODEL_CACHE["updated"]) < MODEL_DISCOVERY_TTL_SECONDS:
528
+ return list(_MODEL_CACHE["models"])
529
+
530
+ headers = {"Authorization": f"Bearer {GROQ_API_KEY}"}
531
+ try:
532
+ async with httpx.AsyncClient(timeout=GROQ_TIMEOUT_SECONDS) as client:
533
+ res = await client.get(f"{GROQ_API_BASE}/models", headers=headers)
534
+ res.raise_for_status()
535
+ data = res.json().get("data", [])
536
+ models = sorted({item.get("id", "") for item in data if item.get("id")})
537
+ _MODEL_CACHE["updated"] = now
538
+ _MODEL_CACHE["models"] = models
539
+ return models
540
+ except Exception:
541
+ return list(_MODEL_CACHE["models"])
542
+
543
+
544
+ def pick_model(available: list[str], override: str, candidates: list[str]) -> Optional[str]:
545
+ if override and override in available:
546
+ return override
547
+
548
+ for m in candidates:
549
+ if m in available:
550
+ return m
551
+ for m in available:
552
+ lowered = m.lower()
553
+ if "instruct" in lowered or "versatile" in lowered or "gpt-oss" in lowered:
554
+ return m
555
+
556
+ return available[0] if available else None
557
+
558
+
559
+ async def groq_chat(model: str, system: str, user: str, temperature: float = 0.2) -> Optional[str]:
560
+ if not GROQ_API_KEY or not model:
561
+ return None
562
+
563
+ headers = {
564
+ "Authorization": f"Bearer {GROQ_API_KEY}",
565
+ "Content-Type": "application/json",
566
+ }
567
+ payload = {
568
+ "model": model,
569
+ "temperature": temperature,
570
+ "messages": [
571
+ {"role": "system", "content": system},
572
+ {"role": "user", "content": user},
573
+ ],
574
+ }
575
+
576
+ try:
577
+ async with httpx.AsyncClient(timeout=GROQ_TIMEOUT_SECONDS) as client:
578
+ res = await client.post(f"{GROQ_API_BASE}/chat/completions", headers=headers, json=payload)
579
+ res.raise_for_status()
580
+ data = res.json()
581
+ return data["choices"][0]["message"]["content"].strip()
582
+ except Exception:
583
+ return None
584
+
585
+
586
+ async def compose_safe_summary(state: AnalysisState, risk_level: str) -> tuple[str, dict[str, Optional[str]]]:
587
+ available = await fetch_available_models()
588
+ reasoning_model = pick_model(available, OVERRIDE_REASONING_MODEL, PREFERRED_REASONING_MODELS)
589
+ safety_model = pick_model(available, OVERRIDE_SAFETY_MODEL, PREFERRED_SAFETY_MODELS)
590
+
591
+ model_meta = {
592
+ "reasoning_model": reasoning_model,
593
+ "safety_model": safety_model,
594
+ }
595
+
596
+ baseline_summary = summary_fallback(state, risk_level)
597
+ if not reasoning_model:
598
+ return baseline_summary, model_meta
599
+
600
+ features_for_prompt = {
601
+ "risk_level": risk_level,
602
+ "overall_cognitive_load": state.overall_load,
603
+ "confidence": state.confidence,
604
+ "scores": {k: v.overall for k, v in state.scores.items()},
605
+ "quality_notes": state.quality_notes,
606
+ "metrics": state.metrics,
607
+ }
608
+ system = (
609
+ "You summarize computational language-screening outputs. "
610
+ "Never diagnose disease, never use alarming wording, and always state uncertainty when confidence is limited. "
611
+ "Output exactly 2-3 sentences in plain text."
612
+ )
613
+ user = "Write a careful summary for this analysis:\n" + json.dumps(features_for_prompt)
614
+
615
+ summary = await groq_chat(reasoning_model, system, user, temperature=0.15)
616
+ if not summary:
617
+ return baseline_summary, model_meta
618
+
619
+ if safety_model:
620
+ safety_system = (
621
+ "You are a safety editor for health-adjacent UX. "
622
+ "Rewrite text to avoid panic, avoid diagnosis claims, and keep uncertainty explicit. "
623
+ "Keep 2-3 sentences."
624
+ )
625
+ safety_user = (
626
+ "Rewrite this summary to be non-alarmist and clinically careful while keeping factual content:\n"
627
+ + summary
628
+ + "\n\nConfidence: "
629
+ + str(state.confidence)
630
+ )
631
+ safe = await groq_chat(safety_model, safety_system, safety_user, temperature=0.1)
632
+ if safe:
633
+ summary = safe
634
+
635
+ return summary, model_meta
636
+
637
+
638
+ @app.get("/health")
639
+ async def health() -> dict[str, Any]:
640
+ available = await fetch_available_models()
641
+ return {
642
+ "ok": True,
643
+ "service": "cortexflow-backend",
644
+ "groq_configured": bool(GROQ_API_KEY),
645
+ "model_count": len(available),
646
+ }
647
+
648
+
649
+ @app.get("/models/recommended")
650
+ async def models_recommended() -> dict[str, Any]:
651
+ available = await fetch_available_models()
652
+ return {
653
+ "available_models": available,
654
+ "recommended": {
655
+ "reasoning": pick_model(available, OVERRIDE_REASONING_MODEL, PREFERRED_REASONING_MODELS),
656
+ "safety": pick_model(available, OVERRIDE_SAFETY_MODEL, PREFERRED_SAFETY_MODELS),
657
+ "transcription": "whisper-large-v3-turbo",
658
+ },
659
+ "notes": {
660
+ "production_primary": "openai/gpt-oss-120b",
661
+ "production_fallback": "llama-3.3-70b-versatile",
662
+ "fast_fallback": "openai/gpt-oss-20b",
663
+ },
664
+ }
665
+
666
+ @app.post("/analyze")
667
+ async def analyze(req: AnalyzeRequest):
668
+ text = ensure_nonempty_text(req)
669
+ session_id = req.session_id or str(uuid.uuid4())
670
+
671
+ async def generate():
672
+ for idx, step_name in enumerate(STEP_NAMES):
673
+ yield safe_step_event(step_name, "running" if idx == 0 else "pending")
674
+
675
+ try:
676
+ state = compute_analysis_state(text, req.pause_map, req.audio_duration)
677
+ yield safe_step_event("STT preprocessor", "done", "Input normalized and validated")
678
+ yield safe_step_event("Lexical agent", "running")
679
+
680
+ await asyncio.sleep(0)
681
+ yield safe_step_event("Lexical agent", "done")
682
+ yield safe_step_event("Semantic agent", "running")
683
+
684
+ await asyncio.sleep(0)
685
+ yield safe_step_event("Semantic agent", "done")
686
+ yield safe_step_event("Prosody agent", "running")
687
+
688
+ await asyncio.sleep(0)
689
+ yield safe_step_event("Prosody agent", "done")
690
+ yield safe_step_event("Syntax agent", "running")
691
+
692
+ await asyncio.sleep(0)
693
+ yield safe_step_event("Syntax agent", "done")
694
+ yield safe_step_event("Biomarker mapper", "running")
695
+
696
+ scores_payload = {
697
+ domain: {**score.details, "overall": score.overall}
698
+ for domain, score in state.scores.items()
699
+ }
700
+
701
+ yield safe_step_event("Biomarker mapper", "done")
702
+ yield safe_step_event("Report composer", "running")
703
+
704
+ risk_level = level_from_overall(state.overall_load, state.confidence)
705
+ summary, model_meta = await compose_safe_summary(state, risk_level)
706
+
707
+ report = {
708
+ "summary": summary,
709
+ "risk_level": risk_level,
710
+ "overall_cognitive_load": state.overall_load,
711
+ "highlights": make_highlights(state),
712
+ "risk_indicators": make_indicators(state),
713
+ "recommendation": recommendation_for_level(risk_level, state.confidence),
714
+ "disclaimer": (
715
+ "This tool is a non-diagnostic screening aid. It can be wrong and must not be used as a standalone "
716
+ "medical decision system. If you are concerned, consult a qualified clinician."
717
+ ),
718
+ "quality": {
719
+ "confidence": state.confidence,
720
+ "notes": state.quality_notes,
721
+ },
722
+ "model_info": model_meta,
723
+ }
724
+ yield safe_step_event("Report composer", "done")
725
+
726
+ payload = {
727
+ "type": "end",
728
+ "message": summary,
729
+ "scores": scores_payload,
730
+ "report": report,
731
+ "session_id": session_id,
732
+ }
733
+ yield (json.dumps(payload) + "\n").encode()
734
+
735
+ except HTTPException as exc:
736
+ yield (json.dumps({"type": "error", "message": exc.detail}) + "\n").encode()
737
+ except Exception as exc:
738
+ yield (json.dumps({"type": "error", "message": f"Analysis failed: {str(exc)}"}) + "\n").encode()
739
+
740
+ return StreamingResponse(
741
+ generate(),
742
+ media_type="text/plain",
743
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
744
+ )
pyproject.toml ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "cortexflow-backend"
3
+ version = "0.1.0"
4
+ description = "CortexFlow deterministic analysis backend with Groq model routing"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ dependencies = [
8
+ "fastapi>=0.115.0",
9
+ "uvicorn[standard]>=0.32.0",
10
+ "httpx>=0.27.0",
11
+ "python-dotenv>=1.0.0",
12
+ "pydantic>=2.10.0",
13
+ ]
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ fastapi==0.115.6
2
+ uvicorn[standard]==0.32.1
3
+ httpx==0.27.2
4
+ python-dotenv==1.0.1
5
+ pydantic==2.10.3