yoursdvniel commited on
Commit
efc826f
·
verified ·
1 Parent(s): dc0b52b

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +397 -0
main.py CHANGED
@@ -3,6 +3,8 @@ from flask_cors import CORS
3
  import json
4
  from datetime import datetime
5
  from typing import Optional, Dict, Any
 
 
6
 
7
  from firestore_client import get_firestore_client
8
  from gemini_client import ask_gpt
@@ -74,6 +76,265 @@ def normalize_plan(plan: dict, token_map: Optional[Dict[str, Any]] = None) -> di
74
 
75
  return {"collections": collections_out}
76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  # -- route ---------------------------------------------------------------
78
 
79
  @app.route('/chat', methods=['POST'])
@@ -277,6 +538,142 @@ def assist():
277
  }
278
  })
279
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
280
 
281
  if __name__ == "__main__":
282
  app.run(host="0.0.0.0", port=7860)
 
3
  import json
4
  from datetime import datetime
5
  from typing import Optional, Dict, Any
6
+ import re
7
+ from typing import List
8
 
9
  from firestore_client import get_firestore_client
10
  from gemini_client import ask_gpt
 
76
 
77
  return {"collections": collections_out}
78
 
79
+ def _norm(v):
80
+ return str(v or "").strip().lower()
81
+
82
+
83
+ def _safe_number(v, default=0):
84
+ try:
85
+ n = float(v)
86
+ return n
87
+ except Exception:
88
+ return default
89
+
90
+
91
+ def _clamp_pct(n):
92
+ try:
93
+ return max(0, min(100, round(float(n))))
94
+ except Exception:
95
+ return 0
96
+
97
+
98
+ def _normalize_metric(metric: Optional[str]) -> Optional[str]:
99
+ m = _norm(metric)
100
+ if not m:
101
+ return None
102
+ if "hour" in m:
103
+ return "hours"
104
+ if "session" in m:
105
+ return "sessions"
106
+ if "document" in m:
107
+ return "documents"
108
+ if "deliverable" in m:
109
+ return "deliverables"
110
+ if "report" in m:
111
+ return "reports"
112
+ if "submission" in m:
113
+ return "submissions"
114
+ return None
115
+
116
+
117
+ def _proof_rules_for_metric(metric_key: Optional[str]) -> List[Dict[str, Any]]:
118
+ if metric_key == "sessions":
119
+ return [
120
+ {
121
+ "type": "attendance",
122
+ "label": "Attendance register / session proof",
123
+ "reason": "Sessions usually require attendance or meeting evidence.",
124
+ "required": True,
125
+ },
126
+ {
127
+ "type": "minutes",
128
+ "label": "Session notes / minutes",
129
+ "reason": "Session outcomes should be supported by notes or minutes.",
130
+ "required": False,
131
+ },
132
+ ]
133
+ if metric_key in ["reports", "documents", "deliverables", "submissions"]:
134
+ return [
135
+ {
136
+ "type": "document",
137
+ "label": "Output file / submitted document",
138
+ "reason": "This metric usually requires the actual file or submission proof.",
139
+ "required": True,
140
+ }
141
+ ]
142
+ if metric_key == "hours":
143
+ return [
144
+ {
145
+ "type": "supporting_evidence",
146
+ "label": "Supporting evidence",
147
+ "reason": "Hours-only updates should still have some supporting evidence where possible.",
148
+ "required": False,
149
+ }
150
+ ]
151
+ return []
152
+
153
+
154
+ def _build_intervention_analysis_prompt(payload: Dict[str, Any]) -> str:
155
+ return f"""
156
+ You are analysing a consultant intervention progress update.
157
+
158
+ Return STRICT JSON only with this shape:
159
+ {{
160
+ "summary": "short summary",
161
+ "polishedNotes": "professional progress notes",
162
+ "activities": ["..."],
163
+ "outputs": ["..."],
164
+ "blockers": ["..."],
165
+ "nextSteps": ["..."],
166
+ "suggestedHours": 0,
167
+ "suggestedUnitsCompleted": 0,
168
+ "confidence": 0,
169
+ "completionSignal": "none|partial|strong",
170
+ "warnings": ["..."],
171
+ "proofHints": [
172
+ {{
173
+ "type": "attendance|minutes|document|photo|supporting_evidence|submission_receipt|other",
174
+ "label": "short label",
175
+ "reason": "why this is needed"
176
+ }}
177
+ ]
178
+ }}
179
+
180
+ Rules:
181
+ - Use the user's notes/transcript/message as the primary source.
182
+ - Do not invent facts not grounded in the input.
183
+ - suggestedHours should be conservative.
184
+ - suggestedUnitsCompleted should only be given if the described work clearly maps to a measurable unit.
185
+ - completionSignal means:
186
+ - "none" = no sign of completion
187
+ - "partial" = meaningful progress but not clearly complete
188
+ - "strong" = strong signal that deliverable/work may be complete
189
+ - warnings should mention ambiguity, weak evidence, or missing detail.
190
+ - polishedNotes should be concise and professional.
191
+
192
+ Intervention context:
193
+ {json.dumps(payload, ensure_ascii=False)}
194
+ """.strip()
195
+
196
+
197
+ def _extract_json_block(text: str) -> Dict[str, Any]:
198
+ text = (text or "").strip()
199
+
200
+ try:
201
+ return json.loads(text)
202
+ except Exception:
203
+ pass
204
+
205
+ match = re.search(r"\{.*\}", text, flags=re.DOTALL)
206
+ if match:
207
+ try:
208
+ return json.loads(match.group(0))
209
+ except Exception:
210
+ pass
211
+
212
+ return {
213
+ "summary": "",
214
+ "polishedNotes": text,
215
+ "activities": [],
216
+ "outputs": [],
217
+ "blockers": [],
218
+ "nextSteps": [],
219
+ "suggestedHours": None,
220
+ "suggestedUnitsCompleted": None,
221
+ "confidence": 40,
222
+ "completionSignal": "none",
223
+ "warnings": ["AI response was not clean JSON. Parsed fallback response."],
224
+ "proofHints": [],
225
+ }
226
+
227
+
228
+ def _merge_proof_hints(metric_rules: List[Dict[str, Any]], ai_hints: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
229
+ merged = []
230
+ seen = set()
231
+
232
+ for item in metric_rules + (ai_hints or []):
233
+ t = _norm(item.get("type"))
234
+ label = str(item.get("label") or "").strip()
235
+ key = f"{t}|{label.lower()}"
236
+ if not t and not label:
237
+ continue
238
+ if key in seen:
239
+ continue
240
+ seen.add(key)
241
+ merged.append({
242
+ "type": t or "other",
243
+ "label": label or "Supporting evidence",
244
+ "reason": str(item.get("reason") or "").strip(),
245
+ "required": bool(item.get("required", False)),
246
+ })
247
+
248
+ return merged
249
+
250
+
251
+ def _calculate_progress_suggestion(intervention: Dict[str, Any], ai_result: Dict[str, Any]) -> Dict[str, Any]:
252
+ target_type = _norm(intervention.get("targetType"))
253
+ target_value = _safe_number(intervention.get("targetValue"), 0)
254
+ current_progress = _clamp_pct(_safe_number(intervention.get("progress"), 0))
255
+ current_actual = _safe_number(intervention.get("targetActual"), 0)
256
+ metric_key = _normalize_metric(intervention.get("targetMetric"))
257
+
258
+ suggested_hours = ai_result.get("suggestedHours")
259
+ try:
260
+ suggested_hours = float(suggested_hours) if suggested_hours is not None else None
261
+ except Exception:
262
+ suggested_hours = None
263
+
264
+ suggested_units = ai_result.get("suggestedUnitsCompleted")
265
+ try:
266
+ suggested_units = float(suggested_units) if suggested_units is not None else None
267
+ except Exception:
268
+ suggested_units = None
269
+
270
+ if target_type == "percentage":
271
+ # For percentage interventions, AI can only suggest a conservative bump.
272
+ base_bump = 0
273
+ signal = _norm(ai_result.get("completionSignal"))
274
+ if signal == "strong":
275
+ base_bump = 20
276
+ elif signal == "partial":
277
+ base_bump = 10
278
+ elif suggested_hours and suggested_hours > 0:
279
+ base_bump = 5
280
+
281
+ suggested_progress = _clamp_pct(current_progress + base_bump)
282
+
283
+ return {
284
+ "targetMode": "percentage",
285
+ "metricKey": metric_key,
286
+ "suggestedHours": suggested_hours,
287
+ "suggestedUnitsCompleted": None,
288
+ "currentActual": None,
289
+ "suggestedActualAfter": None,
290
+ "suggestedProgressPct": suggested_progress,
291
+ "overTargetBy": 0,
292
+ }
293
+
294
+ if target_type == "number" and target_value > 0:
295
+ units = suggested_units
296
+
297
+ if metric_key == "hours" and (units is None or units <= 0):
298
+ units = suggested_hours
299
+
300
+ if units is None or units <= 0:
301
+ return {
302
+ "targetMode": "number",
303
+ "metricKey": metric_key,
304
+ "suggestedHours": suggested_hours,
305
+ "suggestedUnitsCompleted": None,
306
+ "currentActual": current_actual,
307
+ "suggestedActualAfter": current_actual,
308
+ "suggestedProgressPct": current_progress,
309
+ "overTargetBy": 0,
310
+ }
311
+
312
+ actual_after = current_actual + units
313
+ progress_after = _clamp_pct((actual_after / target_value) * 100)
314
+ over_target_by = max(0, actual_after - target_value)
315
+
316
+ return {
317
+ "targetMode": "number",
318
+ "metricKey": metric_key,
319
+ "suggestedHours": suggested_hours,
320
+ "suggestedUnitsCompleted": units,
321
+ "currentActual": current_actual,
322
+ "suggestedActualAfter": actual_after,
323
+ "suggestedProgressPct": progress_after,
324
+ "overTargetBy": over_target_by,
325
+ }
326
+
327
+ return {
328
+ "targetMode": "none",
329
+ "metricKey": metric_key,
330
+ "suggestedHours": suggested_hours,
331
+ "suggestedUnitsCompleted": suggested_units,
332
+ "currentActual": None,
333
+ "suggestedActualAfter": None,
334
+ "suggestedProgressPct": current_progress,
335
+ "overTargetBy": 0,
336
+ }
337
+
338
  # -- route ---------------------------------------------------------------
339
 
340
  @app.route('/chat', methods=['POST'])
 
538
  }
539
  })
540
 
541
+ @app.route('/analyze-intervention-update', methods=['POST'])
542
+ def analyze_intervention_update():
543
+ """
544
+ AI-assisted intervention update analysis.
545
+
546
+ Request JSON:
547
+ {
548
+ "role": "consultant",
549
+ "companyCode": "ABC",
550
+ "userId": "uid123",
551
+ "captureMode": "message|notes|audio",
552
+ "rawInput": "what the user typed or transcript",
553
+ "intervention": {
554
+ "id": "...",
555
+ "interventionTitle": "...",
556
+ "subtitle": "...",
557
+ "beneficiaryName": "...",
558
+ "status": "assigned|in-progress|completed",
559
+ "targetType": "percentage|number",
560
+ "targetMetric": "sessions|hours|reports|...",
561
+ "targetValue": 10,
562
+ "targetActual": 2,
563
+ "progress": 20,
564
+ "dueDate": "...",
565
+ "notes": "..."
566
+ },
567
+ "history": [
568
+ {
569
+ "notes": "...",
570
+ "progressAfter": 20,
571
+ "targetActualAfter": 2,
572
+ "createdAt": "..."
573
+ }
574
+ ]
575
+ }
576
+ """
577
+ data = request.json or {}
578
+
579
+ role = data.get('role')
580
+ company_code = data.get('companyCode')
581
+ user_id = data.get('userId')
582
+ capture_mode = data.get('captureMode')
583
+ raw_input = (data.get('rawInput') or '').strip()
584
+ intervention = data.get('intervention') or {}
585
+ history = data.get('history') or []
586
+
587
+ if not role or not company_code or not user_id or not capture_mode or not raw_input:
588
+ return jsonify({
589
+ "error": "Missing role, companyCode, userId, captureMode, or rawInput"
590
+ }), 400
591
+
592
+ if capture_mode not in ['message', 'notes', 'audio']:
593
+ return jsonify({"error": "Invalid captureMode"}), 400
594
+
595
+ metric_key = _normalize_metric(intervention.get("targetMetric"))
596
+ metric_rules = _proof_rules_for_metric(metric_key)
597
+
598
+ prompt_payload = {
599
+ "captureMode": capture_mode,
600
+ "rawInput": raw_input,
601
+ "role": role,
602
+ "intervention": {
603
+ "id": intervention.get("id"),
604
+ "interventionTitle": intervention.get("interventionTitle"),
605
+ "subtitle": intervention.get("subtitle"),
606
+ "beneficiaryName": intervention.get("beneficiaryName"),
607
+ "status": intervention.get("status"),
608
+ "targetType": intervention.get("targetType"),
609
+ "targetMetric": intervention.get("targetMetric"),
610
+ "targetValue": intervention.get("targetValue"),
611
+ "targetActual": intervention.get("targetActual"),
612
+ "progress": intervention.get("progress"),
613
+ "dueDate": to_jsonable(intervention.get("dueDate")),
614
+ "notes": intervention.get("notes"),
615
+ },
616
+ "history": history[:10],
617
+ "rules": {
618
+ "metricKey": metric_key,
619
+ "proofRules": metric_rules,
620
+ "instruction": (
621
+ "Infer what work was likely done from the raw input, "
622
+ "but do not freely calculate final progress. "
623
+ "Only suggest hours and measurable units if grounded in the text."
624
+ )
625
+ }
626
+ }
627
+
628
+ try:
629
+ system_msg = {
630
+ "role": "system",
631
+ "content": (
632
+ "You are an assistant that analyses intervention update notes for a business workflow. "
633
+ "You must return strict JSON only."
634
+ )
635
+ }
636
+ user_msg = {
637
+ "role": "user",
638
+ "content": _build_intervention_analysis_prompt(prompt_payload)
639
+ }
640
+
641
+ ai_raw = ask_gpt([system_msg, user_msg])
642
+ ai_result = _extract_json_block(ai_raw)
643
+
644
+ progress_calc = _calculate_progress_suggestion(intervention, ai_result)
645
+ merged_proof = _merge_proof_hints(metric_rules, ai_result.get("proofHints", []))
646
+
647
+ completion_signal = _norm(ai_result.get("completionSignal"))
648
+ readiness = "not_ready"
649
+ if completion_signal == "strong":
650
+ readiness = "close"
651
+ elif progress_calc.get("suggestedProgressPct", 0) >= 100:
652
+ readiness = "ready"
653
+
654
+ response = {
655
+ "summary": ai_result.get("summary", ""),
656
+ "polishedNotes": ai_result.get("polishedNotes", ""),
657
+ "activities": ai_result.get("activities", []),
658
+ "outputs": ai_result.get("outputs", []),
659
+ "blockers": ai_result.get("blockers", []),
660
+ "nextSteps": ai_result.get("nextSteps", []),
661
+ "warnings": ai_result.get("warnings", []),
662
+ "confidence": ai_result.get("confidence", 0),
663
+ "completionSignal": completion_signal or "none",
664
+ "completionReadiness": readiness,
665
+ "proofSuggestions": merged_proof,
666
+ "calculation": progress_calc,
667
+ "rawInput": raw_input,
668
+ }
669
+
670
+ return jsonify(to_jsonable(response))
671
+ except Exception as e:
672
+ print("analyze_intervention_update_failed:", e)
673
+ return jsonify({
674
+ "error": "Failed to analyse intervention update"
675
+ }), 500
676
+
677
 
678
  if __name__ == "__main__":
679
  app.run(host="0.0.0.0", port=7860)