websemantic 0.2.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
websemantic/cli.py ADDED
@@ -0,0 +1,592 @@
1
+ """PowerShell-friendly terminal proof of concept. No generated code execution."""
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ from dataclasses import asdict
7
+ from pathlib import Path
8
+
9
+ import yaml
10
+
11
+ from websemantic import web_research
12
+ from websemantic.conversation import explicit_consent, requested_actions
13
+ from websemantic.gemini import GeminiError, extract, load_private_key
14
+ from websemantic.geography import apply_report, location, research, show_report
15
+ from websemantic.registry import environments, execute, load_descriptor
16
+ from websemantic.semantics import describe
17
+ from websemantic.session import ClarificationNeeded, Session
18
+
19
+ ENVIRONMENTS = {str(i): (item['id'], item['namespace'], item['definition'])
20
+ for i, item in enumerate(environments(), 1)}
21
+ AVAILABLE = {item['id'] for item in environments() if item['available']}
22
+ DEFAULT_MODEL = next(item['id'] for item in environments() if item['available'])
23
+
24
+
25
+ def choose_environment():
26
+ while True:
27
+ print('\nWebSemantic — choisissez votre environnement :')
28
+ for number, (_, namespace, definition) in ENVIRONMENTS.items():
29
+ print(f'{number} — {namespace} — {definition}')
30
+ print('q — quitter')
31
+ try:
32
+ choice = input('Votre choix (' + ', '.join(ENVIRONMENTS) + ') > ').strip().lower()
33
+ except (EOFError, KeyboardInterrupt):
34
+ print('\nFin de session.')
35
+ return None
36
+ if choice in ('q', '/q', 'quit', 'exit'):
37
+ return None
38
+ if choice not in ENVIRONMENTS:
39
+ print('Saisissez ' + ', '.join(ENVIRONMENTS) + '.')
40
+ continue
41
+ model, namespace, _ = ENVIRONMENTS[choice]
42
+ if model not in AVAILABLE:
43
+ print(f'{namespace} — Work in progress.')
44
+ continue
45
+ return model
46
+
47
+
48
+ def presentation(session):
49
+ return session.descriptor.get('presentation', {})
50
+
51
+
52
+ def show(session):
53
+ if session.scenarios:
54
+ questions = []
55
+ seen = set()
56
+ for label, child in session.scenarios.items():
57
+ for question in child.questions:
58
+ key = question['question']
59
+ if question['blocking'] and key not in seen:
60
+ questions.append((label, question))
61
+ seen.add(key)
62
+ if questions:
63
+ for label, question in questions[:2]:
64
+ print(f'{label} : {question["question"]}')
65
+ return
66
+ for label, child in session.scenarios.items():
67
+ print(label + ' :')
68
+ show(child)
69
+ for issue in session.result().issues:
70
+ if issue.code == 'comparison_control':
71
+ print('À harmoniser :', describe(issue.field.split('.')[-1], session.descriptor)[0], '— même valeur pour les deux scénarios.')
72
+ return
73
+ if session.pending_clarification:
74
+ print(session.pending_clarification)
75
+ return
76
+ result = session.result()
77
+ if result.decision == 'refuse':
78
+ print('Le logiciel ne permet pas de traiter cette demande.')
79
+ missing = [issue.field for issue in result.issues if issue.code == 'missing']
80
+ if missing:
81
+ names = [describe(field.split('.')[1], session.descriptor)[0] for field in missing[:4]]
82
+ print('À préciser : ' + ', '.join(names) + (f' et {len(missing)-4} autres champs.' if len(missing)>4 else '.'))
83
+ if any('default' in spec for group in ('inputs', 'experiment') for spec in session.descriptor.get(group, {}).values()):
84
+ print('Pour un premier essai : « prends les valeurs par défaut ». Le calcul demandé attend les informations suffisantes.')
85
+ unaccepted = [issue for issue in result.issues if issue.code == 'unaccepted_assumption']
86
+ if unaccepted:
87
+ print(f'{len(unaccepted)} hypothèses attendent votre accord. Demandez le tableau pour les examiner, puis dites « J’accepte les hypothèses proposées » si vous souhaitez les utiliser.')
88
+ for issue in result.issues:
89
+ if issue.code not in ('missing', 'unaccepted_assumption'):
90
+ print(f'À vérifier : {issue.field} ({issue.code}).')
91
+
92
+
93
+ def details(session, full=False):
94
+ if session.scenarios:
95
+ for label, child in session.scenarios.items():
96
+ print('\n' + label)
97
+ details(child, full=full)
98
+ return
99
+ print(f"{'Paramètre':28} | {'Défaut':16} | {'Retenue':16} | {'Unité':16} | Origine / état")
100
+ print('-' * 110)
101
+ for group in ('inputs', 'experiment'):
102
+ for name, spec in session.descriptor.get(group, {}).items():
103
+ primary = presentation(session).get('primary_fields')
104
+ if not full and (spec.get('display_hidden') or (primary and f'{group}.{name}' not in primary)):
105
+ continue
106
+ record = getattr(session.scenario, group).get(name)
107
+ label, unit, _ = describe(name, session.descriptor)
108
+ status = 'manquant' if not record else ('fourni' if record.origin == 'provided' else ('hypothèse validée' if record.accepted else 'à valider'))
109
+ print(f"{label:28} | {spec.get('default', '—')!s:16} | {record.value if record else 'à préciser'!s:16} | {unit:16} | {status}")
110
+ print('Pour aller plus loin, demandez le détail complet, une définition ou une modification en une phrase.')
111
+ for source in sorted({record.source for group in ('inputs', 'experiment') for name, record in getattr(session.scenario, group).items() if record.source and (full or not session.descriptor[group][name].get('operational_default'))}):
112
+ print('Source des hypothèses :', source)
113
+
114
+
115
+ def formulas(session):
116
+ if session.scenarios:
117
+ for label, child in session.scenarios.items():
118
+ print('\n' + label)
119
+ formulas(child)
120
+ return
121
+ entries = session.descriptor.get('model_equations', [])
122
+ if not entries:
123
+ print('Les formules de cet environnement ne sont pas encore documentées.')
124
+ return
125
+ for entry in entries:
126
+ print(f"{entry['label']} [{entry['unit']}] : {entry['expression']}")
127
+ print(entry['meaning'])
128
+ for table in session.descriptor.get('coefficient_display', []):
129
+ group, name = table['field'].split('.')
130
+ record = getattr(session.scenario, group).get(name)
131
+ if record and record.value in table['values']:
132
+ print(table['template'].format(choice=record.value, **table['values'][record.value]))
133
+ print(presentation(session).get('formula_note', 'Les formules et leurs limites sont documentées dans le référentiel du modèle.'))
134
+
135
+
136
+ def ask_questions(session, questions=None):
137
+ for question in session.questions if questions is None else questions:
138
+ if not question['blocking']:
139
+ print('Pour préciser :', question['question'])
140
+
141
+
142
+ def explain(session, name):
143
+ name = name.split('.')[-1]
144
+ from websemantic.units import fold
145
+
146
+ matches = [field for group in ('inputs', 'experiment')
147
+ for field, spec in session.descriptor.get(group, {}).items()
148
+ if fold(name) in {fold(field), fold(spec.get('label', field)),
149
+ *(fold(alias) for alias in spec.get('aliases', []))}]
150
+ if len(matches) == 1:
151
+ name = matches[0]
152
+ for group in ('inputs', 'experiment'):
153
+ if name in session.descriptor.get(group, {}):
154
+ label, unit, definition = describe(name, session.descriptor)
155
+ print(f'{label} — unité : {unit}. {definition}')
156
+ spec = session.descriptor[group][name]
157
+ if 'values' in spec:
158
+ print('Choix possibles : ' + ', '.join(spec['values']))
159
+ print(f'Pour le modifier, indiquez le nouveau choix en précisant {label.lower()} et son unité.')
160
+ record = getattr(session.scenario, group).get(name)
161
+ if record and record.source:
162
+ print('Source :', record.source)
163
+ return
164
+ print('Paramètre non reconnu. Demandez le tableau des paramètres pour retrouver son nom.')
165
+
166
+
167
+ def results_table(session, target, medians):
168
+ if medians.get('comparison') is True:
169
+ print('\nComparaison des médianes des réalisations :')
170
+ print(f"{'Indicateur':24} | {'Scénario 1':16} | {'Scénario 2':16} | {'Écart 1−2':16} | Unité")
171
+ manifest = json.loads((target / 'manifest.json').read_text(encoding='utf-8'))
172
+ for indicator, values in medians['differences'].items():
173
+ spec = session.descriptor['comparison']['indicator_labels'][indicator]
174
+ print(f"{spec['label']:24} | {values['scenario_1']:16.3f} | {values['scenario_2']:16.3f} | {values['difference_1_minus_2']:16.3f} | {spec['unit']}")
175
+ primary = medians['differences'][session.descriptor['comparison']['primary_indicator']]
176
+ difference = primary['difference_1_minus_2']
177
+ if difference == 0:
178
+ print('Les deux scénarios ont la même consommation médiane sur la période simulée.')
179
+ else:
180
+ direction = 'plus' if difference > 0 else 'moins'
181
+ relative = primary['relative_percent_vs_2']
182
+ suffix = f'{abs(relative):.1f} % de {direction}' if relative is not None else f'{direction} (référence nulle : pourcentage indéfini)'
183
+ print(f'Dans cette simulation, le scénario 1 consomme {suffix} que le scénario 2.')
184
+ print('La comparaison dépend de tous les paramètres retenus ; elle ne démontre pas l’effet isolé d’un seul équipement. Données synthétiques ; annualisation par extrapolation. Mêmes graines, sans garantie de tirages identiques après changement de configuration.')
185
+ print('Dossier des résultats :', target)
186
+ for path in manifest['controlled_fields']:
187
+ group, name = path.split('.')
188
+ record = manifest['scenarios']['scenario_1'][group][name]
189
+ if not session.descriptor[group][name].get('display_hidden'):
190
+ label, unit, _ = describe(name, session.descriptor)
191
+ print(f'Commun aux deux scénarios : {label} = {record["value"]} {unit}.')
192
+ return
193
+ config = presentation(session).get('results', {})
194
+ manifest = json.loads((target / 'manifest.json').read_text(encoding='utf-8'))
195
+ scenario = manifest['scenario']
196
+ context_path = target / 'geographic-context.json'
197
+ context = json.loads(context_path.read_text(encoding='utf-8')) if context_path.is_file() else {}
198
+ headline = config.get('headline')
199
+ if headline:
200
+ location_text = headline.get('location_template', ' à {city}').format(city=context['city']) if context.get('city') else ''
201
+ print('\n' + headline['template'].format(value=medians[headline['indicator']], location=location_text))
202
+ recap = []
203
+ for group, names in (('inputs', config.get('recap_fields', [])), ('experiment', config.get('context_fields', []))):
204
+ for name in names:
205
+ if name in scenario[group]:
206
+ label, unit, _ = describe(name, session.descriptor)
207
+ record = scenario[group][name]
208
+ value = record['value']
209
+ if type(value) in (int, float):
210
+ value = f'{value:g}'
211
+ recap.append(f'{label.lower()} {value}' + ('' if unit in ('nombre', 'catégorie', 'identifiant') else ' ' + unit))
212
+ if recap:
213
+ print('Hypothèses retenues : ' + '; '.join(recap) + '.')
214
+ if config.get('indicators') and (not config.get('table') or config.get('deterministic')):
215
+ print(f"{'Indicateur':30} | {'Valeur':14} | Unité")
216
+ for name, label, unit in config['indicators']:
217
+ value = medians.get(name)
218
+ shown = f'{value:.3f}' if type(value) in (int, float) else 'non valide'
219
+ print(f'{label:30} | {shown:14} | {unit}')
220
+ if config.get('table') and not config.get('deterministic'):
221
+ import pandas as pd
222
+
223
+ means = pd.read_csv(target / config['table']).mean(numeric_only=True)
224
+ print(f"{'Indicateur':30} | {'Médiane':12} | {'Moyenne':12} | Unité")
225
+ selected = config.get('display_indicators')
226
+ for name, label, unit in config.get('indicators', []):
227
+ if selected is None or name in selected:
228
+ value = medians.get(name)
229
+ average = means.get(name)
230
+ shown = f'{value:.3f}' if type(value) in (int, float) else 'non valide'
231
+ mean_shown = f'{average:.3f}' if average is not None and pd.notna(average) else 'non valide'
232
+ print(f'{label:30} | {shown:12} | {mean_shown:12} | {unit}')
233
+ for note in config.get('notes', []):
234
+ print(note)
235
+ print('Dossier des résultats :', target)
236
+
237
+
238
+ def suggestions(session, has_results=False):
239
+ ui = presentation(session)
240
+ missing = [issue for issue in session.result().issues if issue.code == 'missing']
241
+ if missing:
242
+ field = missing[0].field.split('.')[-1]
243
+ label, unit, _ = describe(field, session.descriptor)
244
+ fourth = (f'Préciser {label.lower()} ({unit}) et comprendre son rôle.', '/e ' + field)
245
+ else:
246
+ fourth = ('Revoir les hypothèses, leurs unités et leurs sources avant de calculer.', '/details')
247
+ fifth = tuple(ui.get('event_suggestion', ['Comprendre les limites du modèle.', '/uncertainty']))
248
+ if session.geographic_context:
249
+ fifth = (f"Vérifier les hypothèses pour {session.geographic_context['city']}, les unités et leurs sources.", '/details')
250
+ if has_results:
251
+ fifth = ('Comprendre la dispersion et les limites des résultats déjà calculés.', '/uncertainty')
252
+ initial = [tuple(item) for item in ui.get('suggestions', [])[:3]]
253
+ fallback = [('Comprendre le périmètre du logiciel.', '/show'), ('Examiner les paramètres déclarés.', '/details'), ('Comprendre les hypothèses du profil.', '/profile')]
254
+ initial += fallback[len(initial):]
255
+ return initial + [fourth, fifth]
256
+
257
+
258
+ def run_if_ready(session, args, descriptor):
259
+ if not session.run_requested or session.pending_clarification or session.geographic_pending or any(item['blocking'] for item in session.questions) or session.result().decision != 'execute':
260
+ return None
261
+
262
+ print('Calcul en cours…')
263
+ if session.scenarios:
264
+ from websemantic.comparison import run
265
+
266
+ target, medians = run(session.scenarios, descriptor, args.workspace, args.output_dir)
267
+ for child in session.scenarios.values():
268
+ child.run_requested = False
269
+ else:
270
+ target, medians = execute(session.scenario, descriptor, args.workspace, args.output_dir)
271
+ session.run_requested = False
272
+ if getattr(session, 'geographic_context', None):
273
+ (target / 'geographic-context.json').write_text(json.dumps(session.geographic_context, ensure_ascii=False, indent=2), encoding='utf-8')
274
+ if session.web_reports:
275
+ (target / 'web-context.json').write_text(json.dumps(session.web_reports, ensure_ascii=False, indent=2), encoding='utf-8')
276
+ results_table(session, target, medians)
277
+ (target / 'conversation.json').write_text(json.dumps({
278
+ 'environment': presentation(session).get('namespace', args.model), 'llm': args.llm, 'calls': session.calls, 'history': session.history,
279
+ 'validation': asdict(session.result()),
280
+ }, ensure_ascii=False, indent=2), encoding='utf-8')
281
+ if args.open_results and os.name == 'nt':
282
+ os.startfile(target)
283
+ return target, medians
284
+
285
+
286
+ def answer_from_web(session, question, args):
287
+ if args.max_calls and session.calls >= args.max_calls:
288
+ raise GeminiError('Plafond local atteint ; la recherche documentaire nécessite un appel Gemini.')
289
+ load_private_key(args.workspace)
290
+ session.calls += 1
291
+ state = session.state()
292
+ report = web_research.research(question, args.llm, state, descriptor=session.descriptor)
293
+ session.add_questions(report.get('questions', []))
294
+ session.web_reports.append(report)
295
+ session.history.append({'user': question, 'assistant': report['answer'], 'web_context': report})
296
+ output_root = Path(getattr(args, 'output_dir', None) or args.workspace / 'runs')
297
+ output_root.mkdir(parents=True, exist_ok=True)
298
+ with (output_root / 'documentation.jsonl').open('a', encoding='utf-8') as journal:
299
+ journal.write(json.dumps(report, ensure_ascii=False) + '\n')
300
+ web_research.show_report(report, presentation(session).get("short_name", "WebSemantic"))
301
+ for item in report.get('questions', []):
302
+ print('Pour préciser :', item['question'])
303
+
304
+
305
+ def main(argv=None):
306
+ parser = argparse.ArgumentParser(
307
+ description="Websemantic : conversation et simulation scientifique locale."
308
+ )
309
+ parser.add_argument("command", choices=["models", "describe", "chat"])
310
+ parser.add_argument("name", nargs="?", default=DEFAULT_MODEL)
311
+ parser.add_argument("--model", choices=[item[0] for item in ENVIRONMENTS.values()], help="Environnement pour un démarrage direct ou une demande non interactive.")
312
+ parser.add_argument('--direct', action='store_true', help='Démarrage sans menu pour les scripts.')
313
+ parser.add_argument("--llm", default="gemini-3.5-flash-lite")
314
+ parser.add_argument("--max-calls", type=int, default=0, help="0 : sans plafond local (quota fournisseur inchangé).")
315
+ parser.add_argument('--output-dir', type=Path, default=os.environ.get('WEBSEMANTIC_OUTPUT_DIR'))
316
+ parser.add_argument('--open-results', action='store_true')
317
+ parser.add_argument(
318
+ "--workspace",
319
+ type=Path,
320
+ default=Path(
321
+ os.environ.get("WEBSEMANTIC_WORKSPACE", Path(__file__).resolve().parents[2])
322
+ ),
323
+ )
324
+ parser.add_argument(
325
+ "--once", help="Une seule demande sans menu interactif ; le calcul explicitement demandé reste soumis aux contrôles."
326
+ )
327
+ args = parser.parse_args(argv)
328
+ if args.command == "models":
329
+ for model, namespace, definition in ENVIRONMENTS.values():
330
+ print(f"{namespace} — {definition} — " + ('disponible' if model in AVAILABLE else 'Work in progress'))
331
+ return 0
332
+ if args.max_calls < 0:
333
+ parser.error("--max-calls doit être positif ou zéro.")
334
+ if args.name != DEFAULT_MODEL and args.model is None:
335
+ args.model = args.name
336
+ if args.model is not None and args.model not in AVAILABLE:
337
+ print(f'websemantic.{args.model} — Work in progress.')
338
+ if args.once or args.direct or args.command != 'chat':
339
+ return 2
340
+ args.model = None
341
+ if args.command == 'chat' and not args.once and not args.direct:
342
+ args.model = choose_environment()
343
+ if args.model is None:
344
+ return 0
345
+ args.model = args.model or DEFAULT_MODEL
346
+ try:
347
+ descriptor = load_descriptor(args.workspace, args.model)
348
+ except (OSError, ValueError, TypeError):
349
+ print(
350
+ "Descripteur introuvable : utiliser --workspace avec le dossier semantic-sim-layer."
351
+ )
352
+ return 1
353
+ if args.command == "describe":
354
+ print(yaml.safe_dump(descriptor, allow_unicode=True, sort_keys=False))
355
+ return 0
356
+ session = Session(descriptor)
357
+ namespace = presentation(session).get("namespace", "websemantic." + args.model)
358
+ last_output = None
359
+ choices = []
360
+ print(f"WebSemantic — {presentation(session).get('short_name', args.model)}")
361
+ print('Écrivez votre demande : je peux proposer des hypothèses, expliquer les paramètres ou lancer le calcul après validation.')
362
+ print(
363
+ "La validation ou une modification déclenche le calcul dès que le scénario est complet."
364
+ )
365
+ while True:
366
+ try:
367
+ may_run = False
368
+ line = args.once if args.once else input("\n" + namespace + " > ").strip()
369
+ except (EOFError, KeyboardInterrupt):
370
+ print("\nFin de session.")
371
+ return 0
372
+ try:
373
+ if not line:
374
+ continue
375
+ if choices and line in ('1', '2', '3', '4', '5'):
376
+ line = choices[int(line)-1][1]
377
+ choices = []
378
+ print('Choix >', line)
379
+ elif line not in ('/s', '\\s'):
380
+ choices = []
381
+ if line.startswith('\\'):
382
+ line = '/' + line[1:]
383
+ aliases = {'/r': '/run', '/v': '/accept', '/d': '/details', '/p': '/profile', '/a': '/help', '/q': '/quit', '/s': '/suggest'}
384
+ if line.startswith('/') and not line.startswith(('/set ', '/e ', '/web ')):
385
+ line = aliases.get(line.split()[0], line.split()[0])
386
+ if line in ("/quit", "/exit"):
387
+ return 0
388
+ if line == '/suggest':
389
+ choices = suggestions(session, last_output is not None)
390
+ print('Pour préciser votre objectif ou le scénario :')
391
+ for number, (label, _) in enumerate(choices, 1):
392
+ print(f'{number}. {label}')
393
+ print('Saisissez 1 à 5, ou écrivez votre question.')
394
+ elif line == '/events':
395
+ for field in presentation(session).get('events', []):
396
+ explain(session, field)
397
+ elif line == '/uncertainty':
398
+ print(presentation(session).get('uncertainty_text', 'Consultez les limites et incertitudes déclarées du modèle.'))
399
+ elif line == "/help":
400
+ print(
401
+ "/s suggestions | /show résumé | /d tableau | /details-all tous les paramètres | "
402
+ "/e NOM définition | /formulas formules | /p proposer | /v accepter | "
403
+ "/web QUESTION sources | /set GROUPE.NOM VALEUR modifier | /r calculer | /q quitter"
404
+ )
405
+ elif line == "/show":
406
+ show(session)
407
+ elif line == "/details":
408
+ details(session)
409
+ elif line == '/details-all':
410
+ details(session, full=True)
411
+ elif line == '/formulas':
412
+ formulas(session)
413
+ elif line.startswith('/e '):
414
+ explain(session, line.split(maxsplit=1)[1])
415
+ elif line.startswith('/web '):
416
+ answer_from_web(session, line.split(maxsplit=1)[1], args)
417
+ elif line == '/web':
418
+ print('Exemple : /web ' + presentation(session).get('web_example', 'sources scientifiques du modèle'))
419
+ elif line == "/profile":
420
+ session.propose_profile()
421
+ session.history.append({'user': '/profile', 'assistant': 'Profil de démonstration proposé, à valider.'})
422
+ may_run = True
423
+ print(
424
+ "Profil DEMONSTRATION propose, jamais des mesures reelles. Verifiez ci-dessous puis /accept."
425
+ )
426
+ show(session)
427
+ elif line == "/accept":
428
+ if session.geographic_pending:
429
+ print('Le contexte géographique n’a pas abouti. Reformulez la demande avec la ville pour réessayer ; /v ne remplace pas la consultation des sources.')
430
+ if args.once:
431
+ return 1
432
+ continue
433
+ if session.pending_clarification:
434
+ print(session.pending_clarification)
435
+ if args.once:
436
+ return 1
437
+ continue
438
+ session.accept_profile()
439
+ session.run_requested = True
440
+ session.history.append({'user': '/accept', 'assistant': 'Hypothèses acceptées ; calcul automatique soumis aux contrôles.'})
441
+ print(
442
+ "Hypothèses proposées acceptées."
443
+ )
444
+ show(session)
445
+ may_run = True
446
+ elif line.startswith("/set "):
447
+ _, path, text = line.split(" ", 2)
448
+ session.set_value(path, text)
449
+ session.history.append({'user': line, 'assistant': 'Paramètre modifié ; calcul automatique soumis aux contrôles.'})
450
+ show(session)
451
+ may_run = True
452
+ elif line == "/run":
453
+ session.run_requested = True
454
+ may_run = True
455
+ show(session)
456
+ elif line.startswith("/"):
457
+ print("Commande inconnue. /help")
458
+ else:
459
+ if web_research.explicitly_requested(line):
460
+ answer_from_web(session, line, args)
461
+ if args.once:
462
+ return 0
463
+ continue
464
+ previous_run_requested = session.run_requested
465
+ session.request_calculation(line)
466
+ if line.lower().strip() in ('annule le calcul', 'ne calcule pas', 'stop le calcul'):
467
+ print('Demande de calcul en attente annulée. Le scénario est conservé.')
468
+ if args.once:
469
+ return 0
470
+ continue
471
+ if line.lower().strip() in ('calcule', 'calcule maintenant', 'lance le calcul', 'simule', 'fais le calcul', 'recalcule'):
472
+ show(session)
473
+ output = run_if_ready(session, args, descriptor)
474
+ if output:
475
+ last_output = output
476
+ if args.once:
477
+ return 0
478
+ continue
479
+ if last_output and not session.run_requested and ('moyenne' in line.lower() or 'résultat' in line.lower()) and not any(word in line.lower() for word in ('prend', 'utilise', 'profil', 'mix')):
480
+ results_table(session, *last_output)
481
+ print('Ces résultats concernent le dernier calcul enregistré ; /r recalcule le scénario actuel.')
482
+ if args.once:
483
+ return 0
484
+ continue
485
+ place = location(line, descriptor)
486
+ if place and session.geographic_context and session.geographic_context.get('city') == descriptor['geography']['places'][place]['city'] and not any(word in line.lower() for word in ('recherche', 'internet', 'sources', 'actualise', 'documente')):
487
+ place = None
488
+ if place:
489
+ session.geographic_pending = True
490
+ session.pending_clarification = 'Le contexte géographique demandé doit être consulté et validé avant le calcul.'
491
+ if args.max_calls and session.calls + 2 > args.max_calls:
492
+ raise GeminiError('Ce contexte nécessite deux appels Gemini ; plafond local insuffisant.')
493
+ load_private_key(args.workspace)
494
+ session.calls += 1
495
+ parsed, _usage = extract(line, descriptor, session.history, args.llm, state=session.state())
496
+ session.apply(line, parsed)
497
+ session.pending_clarification = 'Analyse géographique en attente ; aucun calcul.'
498
+ session.calls += 1
499
+ report = research(place, args.llm, descriptor)
500
+ apply_report(session, line, report)
501
+ show_report(session)
502
+ show(session)
503
+ if args.once:
504
+ return 0
505
+ continue
506
+ local_message = session.local_intent(line)
507
+ if local_message:
508
+ print(presentation(session).get('short_name', 'WebSemantic') + ' >', local_message)
509
+ show(session)
510
+ output = run_if_ready(session, args, descriptor)
511
+ if output:
512
+ last_output = output
513
+ if args.once:
514
+ return 0
515
+ continue
516
+ if args.max_calls and session.calls >= args.max_calls:
517
+ print(
518
+ "Plafond d'appels atteint. Les commandes locales restent disponibles."
519
+ )
520
+ if args.once:
521
+ return 1
522
+ continue
523
+ session.calls += 1
524
+ load_private_key(args.workspace)
525
+ state = session.state()
526
+ parsed, _usage = extract(line, descriptor, session.history, args.llm, state=state)
527
+ if parsed.get('needs_web') is True:
528
+ if session.run_requested and not previous_run_requested:
529
+ session.pending_clarification = 'Cette demande de calcul nécessite des informations externes. Précisez et validez les paramètres concernés ; /v seul ne résout pas cette demande.'
530
+ session.run_requested = previous_run_requested
531
+ answer_from_web(session, line, args)
532
+ if args.once:
533
+ return 0
534
+ continue
535
+ actions = requested_actions(parsed)
536
+ unresolved = session.pending_clarification
537
+ session.apply(line, parsed)
538
+ if unresolved and not session.scenarios and not parsed['updates'] and actions:
539
+ session.pending_clarification = unresolved
540
+ if 'quit' in actions:
541
+ return 0
542
+ if 'propose' in actions:
543
+ session.propose_profile()
544
+ if 'accept' in actions:
545
+ if session.geographic_pending or session.pending_clarification:
546
+ raise ClarificationNeeded(session.pending_clarification or 'Le contexte documentaire doit être résolu avant validation.')
547
+ if not explicit_consent(line, parsed.get('acceptance_evidence')):
548
+ session.run_requested = False
549
+ raise ClarificationNeeded('Les hypothèses restent non acceptées. Confirmez explicitement votre accord si vous souhaitez les utiliser.')
550
+ session.accept_profile()
551
+ session.run_requested = True
552
+ session.history.append({'user': line, 'assistant': 'Hypothèses explicitement acceptées après extraction des valeurs fournies.'})
553
+ if 'details' in actions:
554
+ details(session, full=parsed.get('detail_level') == 'full')
555
+ if 'explain' in actions:
556
+ explain(session, parsed.get('parameter', ''))
557
+ if 'suggest' in actions:
558
+ for number, (label, _) in enumerate(suggestions(session, last_output is not None), 1):
559
+ print(f'{number}. {label}')
560
+ if 'formulas' in actions:
561
+ formulas(session)
562
+ if not (session.run_requested and session.result().decision == 'execute'):
563
+ print("WebSemantic >", parsed.get("message", ""))
564
+ if parsed["updates"] or session.run_requested or actions & {"propose", "accept"}:
565
+ show(session)
566
+ elif session.pending_clarification:
567
+ print(session.pending_clarification)
568
+ ask_questions(session, parsed.get('questions', []))
569
+ for chunk in parsed.get('scenario_updates', []):
570
+ child = session.scenarios.get(chunk['scenario'])
571
+ if child and any(not item['blocking'] for item in chunk.get('questions', [])):
572
+ print(chunk['scenario'] + ' :')
573
+ ask_questions(child, chunk.get('questions', []))
574
+ may_run = True
575
+ if may_run:
576
+ output = run_if_ready(session, args, descriptor)
577
+ if output:
578
+ last_output = output
579
+ if args.once:
580
+ return 0
581
+ except ClarificationNeeded as exc:
582
+ print(f"\nWebsemantic > {exc}")
583
+ if args.once:
584
+ return 1
585
+ except (GeminiError, ValueError, KeyError, TypeError, OSError) as exc:
586
+ print(f"Erreur : {exc}")
587
+ if args.once:
588
+ return 1
589
+
590
+
591
+ if __name__ == "__main__":
592
+ raise SystemExit(main())