fbdeme commited on
Commit
8ce4a36
ยท
verified ยท
1 Parent(s): bbcfa3d

Deploy cff9def from GitHub Actions

Browse files
__pycache__/agent_contract.cpython-312.pyc CHANGED
Binary files a/__pycache__/agent_contract.cpython-312.pyc and b/__pycache__/agent_contract.cpython-312.pyc differ
 
__pycache__/agent_runtime.cpython-312.pyc CHANGED
Binary files a/__pycache__/agent_runtime.cpython-312.pyc and b/__pycache__/agent_runtime.cpython-312.pyc differ
 
__pycache__/app.cpython-312.pyc CHANGED
Binary files a/__pycache__/app.cpython-312.pyc and b/__pycache__/app.cpython-312.pyc differ
 
__pycache__/feature_program.cpython-312.pyc CHANGED
Binary files a/__pycache__/feature_program.cpython-312.pyc and b/__pycache__/feature_program.cpython-312.pyc differ
 
__pycache__/public_agent.cpython-312.pyc CHANGED
Binary files a/__pycache__/public_agent.cpython-312.pyc and b/__pycache__/public_agent.cpython-312.pyc differ
 
__pycache__/semantic_model.cpython-312.pyc CHANGED
Binary files a/__pycache__/semantic_model.cpython-312.pyc and b/__pycache__/semantic_model.cpython-312.pyc differ
 
__pycache__/upload_predict.cpython-312.pyc CHANGED
Binary files a/__pycache__/upload_predict.cpython-312.pyc and b/__pycache__/upload_predict.cpython-312.pyc differ
 
agent_runtime.py CHANGED
@@ -39,6 +39,10 @@ class SessionRuntime:
39
  def command(self, name, args):
40
  return ""
41
 
 
 
 
 
42
  def _emit(self, **e):
43
  if e["type"] not in ("token", "reasoning"):
44
  log.info("session %s %s %s", self.id, e["type"], e.get("tool") or e.get("state") or "")
@@ -98,6 +102,11 @@ class SessionRuntime:
98
  self.usage = result.context_wrapper.usage
99
  if result.interruptions:
100
  self.state = result.to_state()
 
 
 
 
 
101
  return dict(type="approval", actions=self.pending())
102
  self.state = None
103
  return dict(type="status", state="finished")
 
39
  def command(self, name, args):
40
  return ""
41
 
42
+ def _prepare_approval(self, actions):
43
+ """Adapter hook for binding an approval to its displayed tool inputs."""
44
+ pass
45
+
46
  def _emit(self, **e):
47
  if e["type"] not in ("token", "reasoning"):
48
  log.info("session %s %s %s", self.id, e["type"], e.get("tool") or e.get("state") or "")
 
102
  self.usage = result.context_wrapper.usage
103
  if result.interruptions:
104
  self.state = result.to_state()
105
+ try:
106
+ self._prepare_approval(self.pending())
107
+ except Exception:
108
+ self.state = None
109
+ raise
110
  return dict(type="approval", actions=self.pending())
111
  self.state = None
112
  return dict(type="status", state="finished")
public_agent.py CHANGED
@@ -4,6 +4,7 @@ internal demo. Only the data and result-store adapters differ in this deployment
4
  """
5
  from __future__ import annotations
6
  import asyncio
 
7
  import json
8
  import os
9
  import queue
@@ -127,20 +128,30 @@ def describe(task='all'):
127
  return {'datasets':out}
128
 
129
 
 
 
 
 
 
130
  def ontology(query):
131
  if not isinstance(query,str) or len(query)>4000 or not re.search(r'\b(SELECT|ASK)\b',query,re.I) or re.search(r'\b(SERVICE|FROM|LOAD|INSERT|DELETE|CREATE|DROP|CLEAR|MOVE|COPY|ADD)\b',query,re.I):
132
  raise ValueError('Read-only local SELECT/ASK queries only')
133
  g=rdflib.Graph();d=rdflib.Namespace('urn:ontic:data:');o=rdflib.Namespace('urn:ontic:')
134
  g.bind('d',d);g.bind('ontic',o);g.bind('rdfs',rdflib.RDFS);g.bind('owl',rdflib.OWL);g.bind('xsd',rdflib.XSD)
135
  for key,spec in DATA.items():
 
 
136
  t=d[key];g.add((t,rdflib.RDF.type,rdflib.OWL.Class));g.add((t,rdflib.RDFS.label,rdflib.Literal(spec['name'])))
137
  for i,title in enumerate(spec['sheets'][0]['columns']):
138
  c=d[f'{key}.column{i}'];g.add((c,rdflib.RDFS.domain,t));g.add((c,rdflib.RDFS.label,rdflib.Literal(title)))
139
  g.add((c,rdflib.RDFS.range,rdflib.XSD.string if i==0 else rdflib.XSD.double))
140
- g.add((c,o.sensitivity,rdflib.Literal('none')))
141
- r=g.query(query)
142
- if r.type=='ASK':return {'answer':bool(r.askAnswer)}
143
- return {'rows':[{str(v):str(row[v]) if row[v] is not None else None for v in r.vars} for row in list(r)[:100]]}
 
 
 
144
 
145
 
146
  def llm_features(task,n=4,token=None,requirements=""):
@@ -153,9 +164,12 @@ When the user names specific columns, use ONLY those columns and do not add othe
153
  Use only declared c0/c1/c2/c3 columns. Never use entity_id or labels.
154
  Format: {{"programs":[{{"name":"snake_case","path":["{task}"],"agg":"none","expr":"c0","window_days":null,"rationale":"ํ•œ๊ตญ์–ด ๊ทผ๊ฑฐ"}}]}}.
155
  These are snapshots with no time history. agg must be none and window_days null.
 
156
  Include relevant source columns and optionally defensible arithmetic expressions if requested. No fictitious time aggregations or units. JSON object with programs array only.'''
157
  schema={'type':'object','properties':{'programs':{'type':'array','minItems':1,'maxItems':n,'items':{'type':'object','properties':{'name':{'type':'string'},'path':{'type':'array','items':{'type':'string','enum':[task]},'minItems':1,'maxItems':1},'agg':{'type':'string','enum':['none']},'expr':{'type':'string'},'window_days':{'type':'null'},'rationale':{'type':'string'}},'required':['name','path','agg','expr','window_days','rationale'],'additionalProperties':False}}},'required':['programs'],'additionalProperties':False}
158
- response=OpenAI(base_url=base,api_key=key,timeout=120,max_retries=0).chat.completions.create(model=MODEL,response_format={'type':'json_schema','json_schema':{'name':'feature_programs','schema':schema}},messages=[{'role':'system','content':'You propose ontology-constrained feature programs. Return a JSON object containing programs.'},{'role':'user','content':prompt}],temperature=0,max_tokens=1800)
 
 
159
  text=response.choices[0].message.content or ''
160
  try: programs=json.loads(text)['programs']
161
  except Exception:raise ValueError('Feature proposal was not a programs object; retry propose_features')
@@ -179,15 +193,82 @@ def columns(task):
179
  return {f'c{i}':title for i,title in enumerate(DATA[task]['sheets'][0]['columns'][1:5])}
180
 
181
 
182
- def restrict(task,result,text):
183
- """Columns the user named are a hard constraint, not just a prompt instruction (2026-10-18 local QA: the LLM mixed in others)."""
184
- named={f'c{i}' for i,title in enumerate(DATA[task]['sheets'][0]['columns'][1:5]) if title in (text or '')}
185
- if not named:return result
186
- for p in list(result['programs']):
187
- extra=set(re.findall(r'\bc\d+\b',p['expr']))-named
188
- if extra:result['programs'].remove(p);result['rejected'].append({'program':p,'reason':f'Uses columns the user did not request: {sorted(extra)}'})
189
- if not result['programs']:raise ValueError(f'Every proposal used unrequested columns; propose again using only {sorted(named)}')
190
- return result
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
 
192
 
193
  def observe(ctx,name,fn):
@@ -242,11 +323,23 @@ def show_view(ctx:RunContextWrapper,kind:Literal['schema','table','distribution'
242
 
243
  def describe_data(ctx:RunContextWrapper,task:Literal['all','churn','stockout','defect']='all',summary:str='')->str:
244
  """Read actual workbook schemas, column names, row counts, statistics and ontology. task: all|churn|stockout|defect. summary: short Korean status."""
245
- return observe(ctx,'describe_data',lambda:describe(task))
 
 
 
 
 
 
246
 
247
 
248
  def query_ontology(ctx:RunContextWrapper,query:str,summary:str='')->str:
249
- """Read the published synthetic ontology with local SELECT/ASK SPARQL. Prefixes ontic:,d:,rdfs:,owl:,xsd: are predefined. summary: Korean status."""
 
 
 
 
 
 
250
  return observe(ctx,'query_ontology',lambda:ontology(query))
251
 
252
 
@@ -254,22 +347,64 @@ def propose_features(ctx:RunContextWrapper,task:Literal['churn','stockout','defe
254
  """Use the LLM to synthesize feature programs, then validate against the ontology. task: churn|stockout|defect, n:1-8. summary: Korean status."""
255
  def run():
256
  if not 1<=n<=8:raise ValueError('n must be 1-8')
257
- result=llm_features(task,n,ctx.context.id,requirements or ctx.context.request_text)
258
- result=restrict(task,result,ctx.context.request_text) # the LLM may rewrite requirements; the user's own words still bind
259
- ctx.context.proposals[task]=result;return result
 
 
 
 
 
 
 
 
 
 
 
 
 
260
  return observe(ctx,'propose_features',run)
261
 
262
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
263
  def execute_features(ctx:RunContextWrapper,task:Literal['churn','stockout','defect'],names:list[str]|None=None,summary:str='')->str:
264
  """Execute proposed features after the system obtains user approval. Empty names means all accepted proposals. summary: Korean status."""
265
  def run():
266
- s=ctx.context;proposal=s.proposals.get(task)
267
- if not proposal:raise ValueError('Call propose_features first')
268
- programs=proposal['programs']
269
- if names:
270
- if set(names)-{p['name'] for p in programs}:raise ValueError('Unknown proposed feature')
271
- programs=[p for p in programs if p['name'] in names]
272
- if not programs:raise ValueError('No approved feature programs')
273
  frame,y,subjects,sm=tables(task)
274
  fps=[FeatureProgram.from_dict(p) for p in programs]
275
  for fp in fps:
@@ -312,30 +447,64 @@ def predict(ctx:RunContextWrapper,feat_id:str,summary:str='')->str:
312
  prob=tabfm(X[:len(y)],y,X[len(y):])
313
  rows=sorted([{'row':i,'subject':subject,'probability':float(p)} for i,(subject,p) in enumerate(zip(f['subjects'],prob))],key=lambda r:-r['probability'])
314
  run_id=secrets.token_hex(16)
 
315
  result={'run_id':run_id,'task':f['task'],'features':[p['name'] for p in f['programs']],'programs':f['programs'],'predictions':rows,'validation':{'method':'stratified synthetic holdout, 72 train / 24 validation','auc':auc,'constant_baseline_auc':0.5,'baseline_probability':baseline},'seconds':round(time.monotonic()-started,3),'model':{'repo':'jingang/TabICL','checkpoint':CHECKPOINT,'output':'real_inference'},'columns':columns(f['task']),'caveat':'Synthetic validation only; target outcomes unknown. No causal interpretation or production accuracy claim.'}
 
 
 
 
316
  ctx.context.runs[run_id]=dict(result,feat_id=feat_id);return result
317
  out=observe(ctx,'predict',run);result=json.loads(out)
318
  if 'run_id' not in result:return out
319
- # Evidence is part of every prediction, so the system attaches it for the top-risk row instead of relying on the LLM to call explain.
320
- result['top_evidence']=json.loads(explain_once(ctx,result['run_id'],result['predictions'][0]['row']))
321
- return json.dumps(result,ensure_ascii=False)
322
 
323
 
324
  def explain_once(ctx,run_id,row):
325
  """Emit evidence for a (run, row) once; a repeated request (e.g. the LLM after the automatic one) returns it silently."""
326
  seen=ctx.context.__dict__.setdefault('explained',{})
327
  if (run_id,row) in seen:return json.dumps(seen[run_id,row],ensure_ascii=False)
328
- out=observe(ctx,'explain',lambda:evidence(ctx.context,run_id,row));data=json.loads(out)
 
 
329
  if 'error' not in data:seen[run_id,row]=data
330
  return out
331
 
332
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
333
  def evidence(s,run_id,row):
334
  r=s.runs.get(run_id)
335
  if not r:raise ValueError('Unknown prediction run in this session')
336
- f=s.features[r['feat_id']]
337
- if row<0 or row>=len(f['subjects']):raise ValueError('Unknown target row')
338
- return {'subject':f['subjects'][row],'row':row,'features':[{'feature':p['name'],'expr':p['expr'],'rationale':p['rationale'],'value':float(f['matrix'][96+row,i])} for i,p in enumerate(f['programs'])],'caveat':'Observed feature values, not causal effects or feature importance.'}
339
 
340
 
341
  def explain(ctx:RunContextWrapper,run_id:str,row:int=0,summary:str='')->str:
@@ -355,7 +524,22 @@ def publish_results(ctx:RunContextWrapper,run_id:str,summary:str='')->str:
355
  return {'run_id':run_id,'dashboard_url':'/demo#report='+run_id,'n_rows':len(result['predictions']),'storage':'public synthetic result database, expires after 24 hours or backend restart'}
356
  return observe(ctx,'publish_results',run)
357
 
358
- TOOLS=[function_tool(fn,strict_mode=False,needs_approval=fn is execute_features) for fn in [describe_data,query_ontology,propose_features,execute_features,predict,explain,publish_results,show_view]]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
359
  DEPLOYMENT_NOTES='''
360
  PUBLIC DATA ADAPTER (same responsibilities, different data source):
361
  Available tasks are churn (๊ณ ๊ฐ ์ดํƒˆ), stockout (์žฌ๊ณ  ๋ถ€์กฑ), defect (์ƒ์‚ฐ ๋ถˆ๋Ÿ‰); describe_data(task="all") lists actual downloaded workbook columns.
@@ -363,21 +547,92 @@ Pass requested columns or transformations to propose_features(requirements=...).
363
  These three public datasets are synthetic snapshots, NOT the internal rel-f1 database. There is no Superset MCP in this deployment; publish_results writes the actual result database and provides a report URL. Do not claim a Superset dashboard, production evaluation or official test split. predict supplies synthetic holdout AUROC and a constant baseline, with unknown target outcomes. Features have no physical units.
364
  When the user asks for a picture, diagram, chart, graph, distribution or schema, call show_view and add at most two sentences; never draw ASCII boxes, tables in code blocks or text diagrams. Never show tool names, function-call syntax or parameters to the user, and never describe a view or capability that show_view does not have; if nothing fits, say so plainly in Korean.
365
  For a prediction, keep the original seven-step workflow and execute_features approval pause. For questions or explanations, use data/ontology tools and answer directly without triggering a prediction. Name risk probabilities only from predict output. Explain input evidence and caveats using explain; predict output already includes top_evidence for the highest-risk row. Do not estimate probabilities yourself.
366
- Always finish the workflow with publish_results; the screen turns predict, explain and publish_results observations into cards (ranking, top probability, evidence, report link). After publishing, answer in two or three Korean sentences: what the top entities have in common and what to check next. Do not repeat the probability list or AUROC, do not print report URLs, and refer to inputs by their Korean column names, not c0..c3 or snake_case feature names.
367
  '''
368
 
369
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
370
  class PublicSession(SessionRuntime):
371
  steps=STEPS
372
  own_tools=set(STEPS)
373
  def __init__(self):
374
- super().__init__();self.request_text='';self.proposals={};self.features={};self.runs={};self.touched=time.monotonic()
375
  def send(self,text):
376
  self.request_text=text
377
  return super().send(text)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
378
  def _agent(self,mcp_servers=()):
379
  base,key=credentials(self.id)
380
- return Agent(name='ONTIC',instructions=SYSTEM_PROMPT+'\n'+DEPLOYMENT_NOTES,tools=TOOLS,model_settings=ModelSettings(parallel_tool_calls=False),model=OpenAIChatCompletionsModel(model=MODEL,openai_client=AsyncOpenAI(base_url=base,api_key=key,timeout=120,max_retries=0)))
381
 
382
  SESSIONS={}
383
  SESSIONS_LOCK=threading.Lock()
@@ -449,6 +704,34 @@ def report(run_id:str):
449
 
450
  local_router=APIRouter()
451
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
452
  @local_router.post('/local-llm/v1/chat/completions')
453
  async def local_completion(request:Request):
454
  if not request.client or request.client.host not in ('127.0.0.1','::1'):raise HTTPException(403,'Loopback only')
@@ -460,6 +743,9 @@ async def local_completion(request:Request):
460
  try:result=await asyncio.to_thread(dispatch,'llm',dict(data,model=MODEL,stream=False),strict=True)
461
  except GPUQwenUnavailable as exc:
462
  return JSONResponse({'error':{'message':str(exc),'type':'gpu_qwen_unavailable','code':'GPU_QWEN_UNAVAILABLE','reason':exc.reason}},status_code=503)
 
 
 
463
  if not data.get('stream'):return result
464
  choice=result['choices'][0];message=choice['message'];delta=dict(message)
465
  if message.get('tool_calls'):delta['tool_calls']=[dict(t,index=i) for i,t in enumerate(message['tool_calls'])]
 
4
  """
5
  from __future__ import annotations
6
  import asyncio
7
+ import ast
8
  import json
9
  import os
10
  import queue
 
128
  return {'datasets':out}
129
 
130
 
131
+ # RDFLib/pyparsing share parser state, including the first query's lazy setup.
132
+ # Keep query evaluation and result materialization inside one process-wide lock.
133
+ ONTOLOGY_QUERY_LOCK = threading.Lock()
134
+
135
+
136
  def ontology(query):
137
  if not isinstance(query,str) or len(query)>4000 or not re.search(r'\b(SELECT|ASK)\b',query,re.I) or re.search(r'\b(SERVICE|FROM|LOAD|INSERT|DELETE|CREATE|DROP|CLEAR|MOVE|COPY|ADD)\b',query,re.I):
138
  raise ValueError('Read-only local SELECT/ASK queries only')
139
  g=rdflib.Graph();d=rdflib.Namespace('urn:ontic:data:');o=rdflib.Namespace('urn:ontic:')
140
  g.bind('d',d);g.bind('ontic',o);g.bind('rdfs',rdflib.RDFS);g.bind('owl',rdflib.OWL);g.bind('xsd',rdflib.XSD)
141
  for key,spec in DATA.items():
142
+ *_,sm=tables(key)
143
+ policy_columns=sm.tables[key]['columns']
144
  t=d[key];g.add((t,rdflib.RDF.type,rdflib.OWL.Class));g.add((t,rdflib.RDFS.label,rdflib.Literal(spec['name'])))
145
  for i,title in enumerate(spec['sheets'][0]['columns']):
146
  c=d[f'{key}.column{i}'];g.add((c,rdflib.RDFS.domain,t));g.add((c,rdflib.RDFS.label,rdflib.Literal(title)))
147
  g.add((c,rdflib.RDFS.range,rdflib.XSD.string if i==0 else rdflib.XSD.double))
148
+ code='entity_id' if i==0 else f'c{i-1}'
149
+ sensitivity=policy_columns.get(code,{}).get('sensitivity','none')
150
+ g.add((c,o.sensitivity,rdflib.Literal(sensitivity)))
151
+ with ONTOLOGY_QUERY_LOCK:
152
+ r=g.query(query)
153
+ if r.type=='ASK':return {'answer':bool(r.askAnswer)}
154
+ return {'rows':[{str(v):str(row[v]) if row[v] is not None else None for v in r.vars} for row in list(r)[:100]]}
155
 
156
 
157
  def llm_features(task,n=4,token=None,requirements=""):
 
164
  Use only declared c0/c1/c2/c3 columns. Never use entity_id or labels.
165
  Format: {{"programs":[{{"name":"snake_case","path":["{task}"],"agg":"none","expr":"c0","window_days":null,"rationale":"ํ•œ๊ตญ์–ด ๊ทผ๊ฑฐ"}}]}}.
166
  These are snapshots with no time history. agg must be none and window_days null.
167
+ Rationale must describe the proposed input calculation, not an observed effect on risk. Do not claim feature importance, causal impact, probability amplification or measured interactions. For a ratio, keep the numerator and denominator in the same order as expr. Any usefulness claim is an unvalidated hypothesis.
168
  Include relevant source columns and optionally defensible arithmetic expressions if requested. No fictitious time aggregations or units. JSON object with programs array only.'''
169
  schema={'type':'object','properties':{'programs':{'type':'array','minItems':1,'maxItems':n,'items':{'type':'object','properties':{'name':{'type':'string'},'path':{'type':'array','items':{'type':'string','enum':[task]},'minItems':1,'maxItems':1},'agg':{'type':'string','enum':['none']},'expr':{'type':'string'},'window_days':{'type':'null'},'rationale':{'type':'string'}},'required':['name','path','agg','expr','window_days','rationale'],'additionalProperties':False}}},'required':['programs'],'additionalProperties':False}
170
+ # This bounded structured subrequest needs final JSON, not a thinking trace
171
+ # that can exhaust its output budget. Keep the main agent's reasoning intact.
172
+ response=OpenAI(base_url=base,api_key=key,timeout=120,max_retries=0).chat.completions.create(model=MODEL,response_format={'type':'json_schema','json_schema':{'name':'feature_programs','schema':schema}},messages=[{'role':'system','content':'You propose ontology-constrained feature programs. Return a JSON object containing programs.'},{'role':'user','content':prompt}],temperature=0,max_tokens=1800,reasoning_effort='none')
173
  text=response.choices[0].message.content or ''
174
  try: programs=json.loads(text)['programs']
175
  except Exception:raise ValueError('Feature proposal was not a programs object; retry propose_features')
 
193
  return {f'c{i}':title for i,title in enumerate(DATA[task]['sheets'][0]['columns'][1:5])}
194
 
195
 
196
+ def column_policy(task,text,previous=()):
197
+ """Resolve explicit source-column constraints, including edits to a proposal.
198
+
199
+ This validates the user's column boundary; the LLM still proposes programs.
200
+ Relative exclusions apply to the previous proposal, not every dataset input.
201
+ """
202
+ text=text or '';cols=columns(task);aliases={v:k for k,v in cols.items()}|{k:k for k in cols}
203
+ pattern='|'.join(re.escape(x) for x in sorted(aliases,key=len,reverse=True))
204
+ mentions=[(m.start(),m.end(),aliases[m.group()]) for m in re.finditer(pattern,text)]
205
+ excluded=set()
206
+ protected=[m.span() for m in re.finditer(r'๋นผ์ง€\s*(?:๋ง|์•Š)|์ œ์™ธํ•˜์ง€\s*(?:๋ง|์•Š)|์ œ์™ธ\s*์•ˆ\s*ํ•˜|์•ˆ\s*๋นผ',text)]
207
+ negative=r'์ œ์™ธ(?:ํ•ด|ํ•˜|ํ•œ|ํ• )?|๋นผ(?:๊ณ |์ค˜|์„œ|๋ฉด)?|๋ง๊ณ |(?:์‚ฌ์šฉ|์“ฐ|๋„ฃ)์ง€\s*(?:๋งˆ|๋ง|์•Š)|์‚ฌ์šฉํ•˜์ง€\s*(?:๋งˆ|๋ง|์•Š)|์•ˆ\s*(?:์“ฐ|์‚ฌ์šฉ|๋„ฃ)'
208
+ negatives=[]
209
+ for match in re.finditer(negative,text):
210
+ if any(start<=match.start()<end for start,end in protected):continue
211
+ negatives.append(match)
212
+ before=[m for m in mentions if m[1]<=match.start() and match.start()-m[1]<=45
213
+ and not re.search(r'[.!?ใ€‚\n]',text[m[1]:match.start()])]
214
+ if not before:continue
215
+ group=[before[-1]]
216
+ for candidate in reversed(before[:-1]):
217
+ if re.fullmatch(r'[\s,ใ€ยท/&์™€๊ณผ๋ฐ์ด๋ž‘ํ•˜๊ณ ๋˜๋Š”์„๋ฅผ์€๋Š”๋„๋งŒ]+',text[candidate[1]:group[-1][0]]):group.append(candidate)
218
+ else:break
219
+ excluded.update(m[2] for m in group)
220
+ # Prefix phrasing names what to remove after the verb, e.g. "์ œ์™ธํ•  ์ปฌ๋Ÿผ์€ A".
221
+ for match in re.finditer(r'(?:์ œ์™ธํ• |์ œ์™ธํ•˜๋Š”|๋บ„|๋นผ์•ผ\s*ํ• )\s*(?:(?:์ž…๋ ฅ|์ปฌ๋Ÿผ|์—ด|๋ณ€์ˆ˜|ํ”ผ์ฒ˜|๊ธฐ์ค€)\s*)?(?:์€|๋Š”|์ด|๊ฐ€)?\s*[:๏ผš]?\s*',text):
222
+ after=[m for m in mentions if m[0]>=match.end() and m[0]-match.end()<=45]
223
+ if not after:continue
224
+ group=[after[0]]
225
+ for candidate in after[1:]:
226
+ if re.fullmatch(r'[\s,ใ€ยท/&์™€๊ณผ๋ฐ์ด๋ž‘ํ•˜๊ณ ๋˜๋Š”์„๋ฅผ์€๋Š”๋„๋งŒ]+',text[group[-1][1]:candidate[0]]):group.append(candidate)
227
+ else:break
228
+ excluded.update(m[2] for m in group)
229
+ named={m[2] for m in mentions};positive=named-excluded
230
+ prior=set(re.findall(r'\bc[0-3]\b',' '.join(p.get('expr','') for p in previous)))
231
+ relative=bool(re.search(r'๋‚˜๋จธ์ง€|๋‚จ์€|๋‚จ๊ฒจ|๊ธฐ์กด|์ด์ „|๋ฐฉ๊ธˆ',text))
232
+ all_mentions=list(re.finditer(r'๋ชจ๋“ \s*(?:์ž…๋ ฅ|์ปฌ๋Ÿผ|๋ณ€์ˆ˜)|(?:์ž…๋ ฅ|์ปฌ๋Ÿผ|๋ณ€์ˆ˜)(?:์„|๋ฅผ)?\s*(?:๋ชจ๋‘|์ „๋ถ€)',text))
233
+ negated_all=any(any(a.end()<=m.start()<next((c[0] for c in mentions if c[0]>=a.end()),len(text)) for m in negatives) for a in all_mentions)
234
+ all_inputs=bool(all_mentions) and not negated_all
235
+ if all_inputs:allowed=set(cols)-excluded
236
+ elif negated_all:allowed=positive-excluded
237
+ elif excluded:allowed=(positive or prior or set(cols))-excluded
238
+ elif positive:allowed=positive
239
+ elif relative and prior:allowed=prior
240
+ else:allowed=None
241
+ if allowed is not None and not allowed:raise ValueError('No requested input columns remain; choose another input before proposing features')
242
+ return {'allowed':allowed,'excluded':excluded,'relative':relative}
243
+
244
+
245
+ def restrict(task,result,text,previous=()):
246
+ """Enforce positive and negative column requirements independently of the LLM."""
247
+ policy=column_policy(task,text,previous);allowed=policy['allowed']
248
+ if allowed is None:return result
249
+ accepted=[];rejected=list(result.get('rejected',[]))
250
+ for program in result['programs']:
251
+ extra=set(re.findall(r'\bc\d+\b',program['expr']))-allowed
252
+ if extra:rejected.append({'program':program,'reason':f'Uses columns the user did not request: {sorted(extra)}'})
253
+ else:accepted.append(program)
254
+ if not accepted:raise ValueError(f'Every proposal used unrequested columns; propose again using only {sorted(allowed)}')
255
+ return dict(result,programs=accepted,rejected=rejected)
256
+
257
+
258
+ def feature_definition(task,expr):
259
+ """Describe only validated arithmetic; a model rationale is not an effect."""
260
+ cols=columns(task)
261
+ try:node=ast.parse(expr,mode='eval').body
262
+ except SyntaxError:node=None
263
+ if isinstance(node,ast.Name) and node.id in cols:return 'ํ•ด๋‹น ์ปฌ๋Ÿผ์˜ ์›๋ณธ ์ž…๋ ฅ๊ฐ’'
264
+ if isinstance(node,ast.BinOp) and isinstance(node.left,ast.Name) and isinstance(node.right,ast.Name):
265
+ left,right=cols.get(node.left.id),cols.get(node.right.id)
266
+ if left and right:
267
+ if isinstance(node.op,ast.Div):return f'๋ถ„์ž: {left} ยท ๋ถ„๋ชจ: {right}'
268
+ if isinstance(node.op,ast.Mult):return '๋‘ ์ž…๋ ฅ๊ฐ’์„ ๊ณฑํ•œ ๊ฐ’'
269
+ if isinstance(node.op,ast.Add):return '๋‘ ์ž…๋ ฅ๊ฐ’์„ ๋”ํ•œ ๊ฐ’'
270
+ if isinstance(node.op,ast.Sub):return '์•ž ์ž…๋ ฅ๊ฐ’์—์„œ ๋’ค ์ž…๋ ฅ๊ฐ’์„ ๋บ€ ๊ฐ’'
271
+ return 'ํ‘œ์‹œ๋œ ๊ณ„์‚ฐ์‹์„ ์›๋ณธ ์ž…๋ ฅ์— ์ ์šฉํ•œ ๊ฐ’'
272
 
273
 
274
  def observe(ctx,name,fn):
 
323
 
324
  def describe_data(ctx:RunContextWrapper,task:Literal['all','churn','stockout','defect']='all',summary:str='')->str:
325
  """Read actual workbook schemas, column names, row counts, statistics and ontology. task: all|churn|stockout|defect. summary: short Korean status."""
326
+ out=observe(ctx,'describe_data',lambda:describe(task));data=json.loads(out)
327
+ if 'error' in data:return out
328
+ # The feature synthesizer has the executable mapping separately. Ordinary
329
+ # data questions need source names, not its internal c0/c1 aliases.
330
+ for dataset in data['datasets']:
331
+ dataset.pop('feature_mapping',None);dataset.pop('semantic_model',None)
332
+ return json.dumps(data,ensure_ascii=False)
333
 
334
 
335
  def query_ontology(ctx:RunContextWrapper,query:str,summary:str='')->str:
336
+ """Read local SELECT/ASK SPARQL. Prefixes ontic:,d:,rdfs:,owl:,xsd: are predefined.
337
+ Tables are d:churn, d:stockout, d:defect. Columns have rdfs:domain,
338
+ rdfs:label, rdfs:range and ontic:sensitivity; nullRate/distinct are absent.
339
+ Example: SELECT ?col ?label ?sensitivity WHERE { ?col rdfs:domain d:churn; rdfs:label ?label; ontic:sensitivity ?sensitivity }
340
+ To select an ID use FILTER(?col = d:churn.column0), never a bare ?col = ... triple.
341
+ summary: Korean status.
342
+ """
343
  return observe(ctx,'query_ontology',lambda:ontology(query))
344
 
345
 
 
347
  """Use the LLM to synthesize feature programs, then validate against the ontology. task: churn|stockout|defect, n:1-8. summary: Korean status."""
348
  def run():
349
  if not 1<=n<=8:raise ValueError('n must be 1-8')
350
+ previous=ctx.context.proposals.get(task,{}).get('programs',[])
351
+ policy=column_policy(task,ctx.context.request_text,previous)
352
+ requested=requirements or ctx.context.request_text
353
+ if policy['relative'] and policy['allowed'] is not None:
354
+ requested+='\n์ด๋ฒˆ ์ œ์•ˆ์— ํ—ˆ์šฉ๋œ ์ž…๋ ฅ ์ปฌ๋Ÿผ์€ '+', '.join(columns(task)[c] for c in sorted(policy['allowed']))+'๋ฟ์ž…๋‹ˆ๋‹ค. ๋‹ค๋ฅธ ์ž…๋ ฅ์„ ์ถ”๊ฐ€ํ•˜์ง€ ๋งˆ์„ธ์š”.'
355
+ result=llm_features(task,n,ctx.context.id,requested)
356
+ result=restrict(task,result,ctx.context.request_text,previous) # the user's own edit still binds after LLM rewriting
357
+ if policy['allowed'] is not None:
358
+ result['input_boundary']={'source':'latest_user_request_and_previous_proposal',
359
+ 'allowed_columns':[columns(task)[c] for c in sorted(policy['allowed'])],
360
+ 'excluded_columns':[columns(task)[c] for c in sorted(policy['excluded'])],
361
+ 'relative_to_previous_proposal':policy['relative'],
362
+ 'note':'์ด ๋ฒ”์œ„๋Š” ์‚ฌ์šฉ์ž ์›๋ฌธ์—์„œ ๊ฒ€์ฆํ–ˆ์Šต๋‹ˆ๋‹ค. ์ž…๋ ฅ์ด ํ•˜๋‚˜๋งŒ ๋‚จ์•„๋„ ์›๊ฐ’์ด๋‚˜ ํ•ด๋‹น ์ž…๋ ฅ๋งŒ์˜ ๊ณ„์‚ฐ์€ ์œ ํšจํ•ฉ๋‹ˆ๋‹ค. ๋‹ค๋ฅธ ์ปฌ๋Ÿผ์„ ์ถ”๊ฐ€ํ•˜๋ ค๊ณ  ๋ฐ˜๋ณต ์ œ์•ˆํ•˜์ง€ ๋งˆ์„ธ์š”. n์€ ์ตœ๋Œ€ ๊ฐœ์ˆ˜์ž…๋‹ˆ๋‹ค.'}
363
+ result['definitions']={p['name']:feature_definition(task,p['expr']) for p in result['programs']}
364
+ result['rationale_status']='model_proposal_not_empirical_effect'
365
+ ctx.context.proposals[task]=result;ctx.context.proposal_task=task;return result
366
  return observe(ctx,'propose_features',run)
367
 
368
 
369
+ def _execution_programs(session,task,names=None):
370
+ """Select the exact requested programs without widening the latest scope.
371
+
372
+ Invalid selections are rejected together; no valid subset executes silently.
373
+ The same check runs before SDK approval and again before feature execution.
374
+ """
375
+ proposal=session.proposals.get(task)
376
+ if not proposal:raise ValueError('Call propose_features first')
377
+ programs=proposal['programs']
378
+ if names is not None and (not isinstance(names,list) or any(not isinstance(name,str) for name in names)):
379
+ raise ValueError('Feature names must be a list of proposed names')
380
+ if names and (len(names)!=len(set(names)) or set(names)-{p['name'] for p in programs}):
381
+ raise ValueError('Unknown or duplicate proposed feature names')
382
+ selected=[p for p in programs if not names or p['name'] in names]
383
+ if not selected:raise ValueError('No approved feature programs')
384
+ allowed=column_policy(task,session.request_text,programs)['allowed']
385
+ def within_scope(program):
386
+ return allowed is None or not (set(re.findall(r'\bc\d+\b',program['expr']))-allowed)
387
+ invalid=[p['name'] for p in selected if not within_scope(p)]
388
+ if invalid:
389
+ remaining=[p['name'] for p in programs if within_scope(p)]
390
+ raise ValueError(f'Programs violate latest requested input columns: {invalid}. '
391
+ f'Propose again or request fresh approval for remaining names: {remaining}')
392
+ return json.loads(json.dumps(selected,ensure_ascii=False))
393
+
394
+
395
+ async def execution_needs_approval(ctx,arguments,call_id):
396
+ """Only valid selections reach approval; invalid ones emit tool errors."""
397
+ try:
398
+ _execution_programs(ctx.context,arguments.get('task'),arguments.get('names'))
399
+ except ValueError:
400
+ return False
401
+ return True
402
+
403
+
404
  def execute_features(ctx:RunContextWrapper,task:Literal['churn','stockout','defect'],names:list[str]|None=None,summary:str='')->str:
405
  """Execute proposed features after the system obtains user approval. Empty names means all accepted proposals. summary: Korean status."""
406
  def run():
407
+ s=ctx.context;programs=_execution_programs(s,task,names)
 
 
 
 
 
 
408
  frame,y,subjects,sm=tables(task)
409
  fps=[FeatureProgram.from_dict(p) for p in programs]
410
  for fp in fps:
 
447
  prob=tabfm(X[:len(y)],y,X[len(y):])
448
  rows=sorted([{'row':i,'subject':subject,'probability':float(p)} for i,(subject,p) in enumerate(zip(f['subjects'],prob))],key=lambda r:-r['probability'])
449
  run_id=secrets.token_hex(16)
450
+ if not rows:raise ValueError('Prediction did not return any target rows')
451
  result={'run_id':run_id,'task':f['task'],'features':[p['name'] for p in f['programs']],'programs':f['programs'],'predictions':rows,'validation':{'method':'stratified synthetic holdout, 72 train / 24 validation','auc':auc,'constant_baseline_auc':0.5,'baseline_probability':baseline},'seconds':round(time.monotonic()-started,3),'model':{'repo':'jingang/TabICL','checkpoint':CHECKPOINT,'output':'real_inference'},'columns':columns(f['task']),'caveat':'Synthetic validation only; target outcomes unknown. No causal interpretation or production accuracy claim.'}
452
+ # Attach the actual evidence before observe() emits the prediction and
453
+ # before the run is stored for publish_results(). The return value,
454
+ # result card and stored report must describe the same observation.
455
+ result['top_evidence']=_feature_evidence(f,rows[0]['row'])
456
  ctx.context.runs[run_id]=dict(result,feat_id=feat_id);return result
457
  out=observe(ctx,'predict',run);result=json.loads(out)
458
  if 'run_id' not in result:return out
459
+ # Preserve predict -> explain ordering and one explain event per row.
460
+ explain_once(ctx,result['run_id'],result['predictions'][0]['row'])
461
+ return out
462
 
463
 
464
  def explain_once(ctx,run_id,row):
465
  """Emit evidence for a (run, row) once; a repeated request (e.g. the LLM after the automatic one) returns it silently."""
466
  seen=ctx.context.__dict__.setdefault('explained',{})
467
  if (run_id,row) in seen:return json.dumps(seen[run_id,row],ensure_ascii=False)
468
+ stored=ctx.context.runs.get(run_id,{})
469
+ top=stored.get('top_evidence')
470
+ out=observe(ctx,'explain',lambda:top if isinstance(top,dict) and top.get('row')==row else evidence(ctx.context,run_id,row));data=json.loads(out)
471
  if 'error' not in data:seen[run_id,row]=data
472
  return out
473
 
474
 
475
+ def _feature_evidence(f,row):
476
+ """Observed source inputs and computed features, without attribution claims."""
477
+ if row<0 or row>=len(f['subjects']):raise ValueError('Unknown target row')
478
+ target=DATA[f['task']]['sheets'][1]
479
+ names=columns(f['task'])
480
+ used={code for p in f['programs'] for code in re.findall(r'\bc[0-3]\b',p['expr'])}
481
+ inputs=[]
482
+ for i,(code,name) in enumerate(names.items()):
483
+ if code not in used:continue
484
+ values=[float(source[i+1]) for source in target['rows']]
485
+ value=values[row]
486
+ inputs.append({'column_code':code,'column':name,'value':value,'unit':None,
487
+ 'target_rank_ascending':1+sum(other<value for other in values),
488
+ 'target_tie_count':sum(other==value for other in values),
489
+ 'target_count':len(values),'target_min':min(values),'target_max':max(values)})
490
+ features=[]
491
+ for i,p in enumerate(f['programs']):
492
+ refs=set(re.findall(r'\bc[0-3]\b',p['expr']))
493
+ features.append({'feature':p['name'],'expr':p['expr'],
494
+ 'display_expr':re.sub(r'\bc[0-3]\b',lambda match:names[match.group()],p['expr']),
495
+ 'rationale':p['rationale'],'value':float(f['matrix'][len(f['labels'])+row,i]),
496
+ 'input_columns':[name for code,name in names.items() if code in refs],'unit':None})
497
+ return {'subject':f['subjects'][row],'row':row,'task':f['task'],'source_sheet':target['name'],
498
+ 'input_values':inputs,'features':features,
499
+ 'unit_description':'Dimensionless synthetic relative values; no physical units are defined.',
500
+ 'caveat':'Observed source inputs and computed feature values, not causal effects or feature importance. '
501
+ 'Risk probability does not establish whether an input is high or low; compare actual observed values before making that claim.'}
502
+
503
+
504
  def evidence(s,run_id,row):
505
  r=s.runs.get(run_id)
506
  if not r:raise ValueError('Unknown prediction run in this session')
507
+ return _feature_evidence(s.features[r['feat_id']],row)
 
 
508
 
509
 
510
  def explain(ctx:RunContextWrapper,run_id:str,row:int=0,summary:str='')->str:
 
524
  return {'run_id':run_id,'dashboard_url':'/demo#report='+run_id,'n_rows':len(result['predictions']),'storage':'public synthetic result database, expires after 24 hours or backend restart'}
525
  return observe(ctx,'publish_results',run)
526
 
527
+ def _execution_tool():
528
+ from agents import FunctionTool
529
+ decorated=function_tool(execute_features,strict_mode=False)
530
+ async def invoke(ctx,arguments):
531
+ return await decorated.on_invoke_tool(ctx,arguments)
532
+ # SDK 0.22.3 forces manual approval before a decorated policy whenever
533
+ # defaults/order change during argument validation. A public FunctionTool
534
+ # policy inspects the raw task/names; invocation retains the decorator's
535
+ # complete argument validation and the body's second scope check.
536
+ return FunctionTool(name=decorated.name,description=decorated.description,
537
+ params_json_schema=decorated.params_json_schema,
538
+ strict_json_schema=decorated.strict_json_schema,
539
+ on_invoke_tool=invoke,needs_approval=execution_needs_approval)
540
+
541
+
542
+ TOOLS=[_execution_tool() if fn is execute_features else function_tool(fn,strict_mode=False) for fn in [describe_data,query_ontology,propose_features,execute_features,predict,explain,publish_results,show_view]]
543
  DEPLOYMENT_NOTES='''
544
  PUBLIC DATA ADAPTER (same responsibilities, different data source):
545
  Available tasks are churn (๊ณ ๊ฐ ์ดํƒˆ), stockout (์žฌ๊ณ  ๋ถ€์กฑ), defect (์ƒ์‚ฐ ๋ถˆ๋Ÿ‰); describe_data(task="all") lists actual downloaded workbook columns.
 
547
  These three public datasets are synthetic snapshots, NOT the internal rel-f1 database. There is no Superset MCP in this deployment; publish_results writes the actual result database and provides a report URL. Do not claim a Superset dashboard, production evaluation or official test split. predict supplies synthetic holdout AUROC and a constant baseline, with unknown target outcomes. Features have no physical units.
548
  When the user asks for a picture, diagram, chart, graph, distribution or schema, call show_view and add at most two sentences; never draw ASCII boxes, tables in code blocks or text diagrams. Never show tool names, function-call syntax or parameters to the user, and never describe a view or capability that show_view does not have; if nothing fits, say so plainly in Korean.
549
  For a prediction, keep the original seven-step workflow and execute_features approval pause. For questions or explanations, use data/ontology tools and answer directly without triggering a prediction. Name risk probabilities only from predict output. Explain input evidence and caveats using explain; predict output already includes top_evidence for the highest-risk row. Do not estimate probabilities yourself.
550
+ Always finish the workflow with publish_results; the screen turns predict, explain and publish_results observations into cards (ranking, top probability, evidence, report link). After publishing, answer in two or three Korean sentences using an observable fact from top_evidence and a cautious next check. Evidence is observed input, not feature importance or the reason for the probability. Never call inputs high or low just because predicted risk is high: use the supplied actual value and target rank/range, or omit the comparison. Only the highest-risk row has automatic evidence; do not claim other subjects share its values without querying their evidence. For arithmetic features, keep the actual expression's numerator and denominator. Program rationale is an unvalidated model proposal, not empirical evidence. Use the host's definitions and actual feature expression for arithmetic facts. Do not invent causal effects or model contributions. Read entity counts and distinctness from the tool results. Do not repeat the probability list or AUROC, do not print report URLs, and refer to inputs by their Korean column names, not c0..c3 or snake_case feature names.
551
  '''
552
 
553
 
554
+ DEPLOYMENT_NOTES+='''
555
+ ํ‘œํ˜„ ๊ฒ€์ฆ: ์‹ค์ œ execute_features ๊ด€์ธก์ด ๋‚˜์˜ค๊ธฐ ์ „์—๋Š” ์Šน์ธ ์™„๋ฃŒ๋‚˜ ์‹คํ–‰ ์™„๋ฃŒ๋ผ๊ณ  ๋งํ•˜์ง€ ๋งˆ์„ธ์š”. ์Šน์ธ ์นด๋“œ๋ฅผ ๊ธฐ๋‹ค๋ฆฌ๋Š” ์ƒํƒœ๋Š” ์Šน์ธ ๋Œ€๊ธฐ์ž…๋‹ˆ๋‹ค.
556
+ ํ˜„์žฌ ๋ฐ์ดํ„ฐ์— ์—†๋Š” ๋งค์ถœยท์‹ค๋ช…ยท๋‚ ์งœยท์™ธ๋ž˜ํ‚คยท์‹œ๊ฐ„ ์ด๋ ฅ ๋ถ„์„์„ ๊ฐ€๋Šฅํ•œ ์˜ˆ์‹œ๋กœ ์ œ์•ˆํ•˜์ง€ ๋งˆ์„ธ์š”. ์š”์ฒญํ•œ ์ปฌ๋Ÿผ์ด ์—†์œผ๋ฉด ์‹ค์ œ ์ปฌ๋Ÿผ์„ ์•ˆ๋‚ดํ•˜๊ณ  ๋œป์„ ํ™•์ธํ•˜๋ฉฐ, ์กฐ์šฉํžˆ ๋‹ค๋ฅธ ์ปฌ๋Ÿผ์œผ๋กœ ๋ฐ”๊พธ๊ฑฐ๋‚˜ ์ผ๋ถ€๋งŒ ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์œผ๋กœ ๋‹จ์ •ํ•˜์ง€ ๋งˆ์„ธ์š”.
557
+ ์ฐจํŠธ ์„ค๋ช…์€ ์‹ค์ œ ํ‘œ์‹œ๋œ ์ข…๋ฅ˜์™€ ์ˆ˜์น˜๋ฅผ ๊ธฐ์ค€์œผ๋กœ ํ•˜์„ธ์š”. ๋ชจ๋“  ๊ตฌ๊ฐ„์„ ๋น„๊ตํ•˜์ง€ ์•Š๊ณ  ๋‹จ์กฐ ์ฆ๊ฐ€ยท๊ฐ์†Œ๋ผ๊ณ  ์ผ๋ฐ˜ํ™”ํ•˜์ง€ ๋งˆ์„ธ์š”. ํ•ฉ์„ฑ ์Šค๋ƒ…์ƒท ๊ฐ’์— ์ž…์ฆ๋˜์ง€ ์•Š์€ ๋ฌผ๋ฆฌ์  ์˜๋ฏธ๋‚˜ ์ธ๊ณผ ํšจ๊ณผ๋ฅผ ๋ถ™์ด์ง€ ๋งˆ์„ธ์š”. ์ž์—ฐ์Šค๋Ÿฌ์šด ํ•œ๊ตญ์–ด๋งŒ ์‚ฌ์šฉํ•˜์„ธ์š”.
558
+ '''
559
+
560
+
561
+ def public_instructions():
562
+ """Use the original tool roles with the public adapter's metrics and style."""
563
+ adapted={
564
+ '4. predict':'4. predict โ†’ actual TabICL probabilities and synthetic holdout metrics from the tool; target outcomes are unknown.',
565
+ '5. explain':'5. explain โ†’ actual observed inputs and feature calculations for the requested target, without causal or importance claims.',
566
+ '6. publish_results':'6. publish_results โ†’ save the actual results and evidence and provide the report link to the screen. Always call this tool to complete a prediction.',
567
+ '7. Answer':'7. Answer in Korean. After prediction, use two or three concise sentences about observed input and a cautious next check. The screen already shows probability ranking, metrics and the report link; do not repeat them or output a URL. For column questions use the actual Korean names, never internal c0/c1/c2/c3 aliases.',
568
+ }
569
+ lines=['You are ONTIC, a data agent for the three published synthetic snapshot workbooks. The ontology constrains available tables and columns. You answer questions, synthesize feature programs and choose tools; the executor computes approved feature expressions and an anonymous matrix goes to TabICL for actual in-context numerical prediction. Do not train model weights or claim access to an internal relational customer database.'];ontology_shape=False;workflow=False
570
+ for line in SYSTEM_PROMPT.splitlines():
571
+ if not workflow:
572
+ if not line.startswith('Workflow for a prediction question:'):continue
573
+ workflow=True
574
+ if line.startswith('Superset tools ('):continue
575
+ if line.startswith('Ontology shape:'):
576
+ ontology_shape=True
577
+ lines.append('Public ontology: tables are owl:Class d:churn, d:stockout, d:defect with rdfs:label. Columns d:<task>.column0..column5 have rdfs:domain, rdfs:label, rdfs:range and ontic:sensitivity. No foreign keys, temporal relations, primaryKey, nullRate, distinct or Metric predicates are published. Use FILTER(?col = d:churn.column0) to restrict the row-key column.')
578
+ continue
579
+ if ontology_shape:
580
+ if not line.startswith('Write the `summary`'):continue
581
+ ontology_shape=False
582
+ lines.append(next((replacement for prefix,replacement in adapted.items() if line.startswith(prefix)),line))
583
+ return '\n'.join(lines)+'\n'+DEPLOYMENT_NOTES
584
+
585
+
586
+ def live_public_instructions(ctx,agent):
587
+ """SDK resumes its stored agent; resolve current user scope on every call."""
588
+ return ctx.context.current_instructions()
589
+
590
+
591
  class PublicSession(SessionRuntime):
592
  steps=STEPS
593
  own_tools=set(STEPS)
594
  def __init__(self):
595
+ super().__init__();self.request_text='';self.proposals={};self.proposal_task=None;self.features={};self.runs={};self.touched=time.monotonic()
596
  def send(self,text):
597
  self.request_text=text
598
  return super().send(text)
599
+ def confirm(self,approve,reason=''):
600
+ if not approve and reason.strip():
601
+ self.request_text=reason
602
+ task=self.proposal_task
603
+ if task in self.proposals:
604
+ try:policy=column_policy(task,reason,self.proposals[task]['programs'])
605
+ except ValueError:
606
+ reason+='\n[์ž…๋ ฅ ๋ฒ”์œ„ ๊ฒ€์ฆ] ์ด์ „์— ์ œ์•ˆํ•œ ์ž…๋ ฅ ์ค‘ ํ—ˆ์šฉ๋œ ์ž…๋ ฅ์ด ๋‚จ์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. ์ƒˆ ์ž…๋ ฅ์„ ํ™•์ธํ•˜๊ธฐ ์ „์—๋Š” ์‹คํ–‰ํ•˜์ง€ ๋งˆ์„ธ์š”.'
607
+ else:
608
+ if policy['allowed'] is not None:
609
+ allowed=', '.join(columns(task)[c] for c in sorted(policy['allowed']))
610
+ reason+='\n[์ž…๋ ฅ ๋ฒ”์œ„ ๊ฒ€์ฆ] ํ˜„์žฌ '+task+'์— ํ—ˆ์šฉ๋œ ์ž…๋ ฅ์€ '+allowed+'๋ฟ์ž…๋‹ˆ๋‹ค. ๋‚˜๋จธ์ง€๋Š” ์ง์ „ ์ œ์•ˆ์—์„œ ์ œ์™ธ ํ›„ ๋‚จ์€ ์ž…๋ ฅ์ด๋ฉฐ ๋‹ค๋ฅธ ๋ฐ์ดํ„ฐ ์ปฌ๋Ÿผ์„ ์ƒˆ๋กœ ํ—ˆ์šฉํ•œ ๋œป์ด ์•„๋‹™๋‹ˆ๋‹ค. ํ•œ ์ž…๋ ฅ๋งŒ ๋‚จ์œผ๋ฉด ์›๊ฐ’๋งŒ ์‚ฌ์šฉํ•ด๋„ ๋ฉ๋‹ˆ๋‹ค.'
611
+ return super().confirm(approve,reason)
612
+ def pending(self):
613
+ actions=super().pending()
614
+ for action in actions:
615
+ if action['tool']=='execute_features':
616
+ task=action['args'].get('task');programs=_execution_programs(self,task,action['args'].get('names'))
617
+ action['programs']=programs;action['columns']=columns(task)
618
+ return actions
619
+ def current_instructions(self):
620
+ instructions=public_instructions()
621
+ task=self.proposal_task
622
+ if task in self.proposals:
623
+ # The same user boundary enforced by tools must also be visible to
624
+ # the deciding agent, before it rewrites a relative edit as a request.
625
+ try:policy=column_policy(task,self.request_text,self.proposals[task]['programs'])
626
+ except ValueError:
627
+ instructions+='\nCURRENT USER INPUT BOUNDARY: No requested inputs remain for '+task+'. Ask the user to choose inputs before proposing or executing.'
628
+ else:
629
+ if policy['allowed'] is not None:
630
+ allowed=', '.join(columns(task)[c] for c in sorted(policy['allowed']))
631
+ instructions+='\nCURRENT USER INPUT BOUNDARY for '+task+': '+allowed+' only. This comes from the latest user request and the previous proposal. Relative edits such as ๋‚˜๋จธ์ง€ retain the remaining previously proposed inputs; they do not authorize other dataset columns. Follow it when proposing or selecting programs. Choose whether to answer, propose or execute according to the user request; execution still requires fresh approval.'
632
+ return instructions
633
  def _agent(self,mcp_servers=()):
634
  base,key=credentials(self.id)
635
+ return Agent(name='ONTIC',instructions=live_public_instructions,tools=TOOLS,model_settings=ModelSettings(temperature=0,parallel_tool_calls=False),model=OpenAIChatCompletionsModel(model=MODEL,openai_client=AsyncOpenAI(base_url=base,api_key=key,timeout=120,max_retries=0)))
636
 
637
  SESSIONS={}
638
  SESSIONS_LOCK=threading.Lock()
 
704
 
705
  local_router=APIRouter()
706
 
707
+
708
+ def visible_answer(text):
709
+ """Remove Qwen's reasoning protocol from its final answer, before streaming."""
710
+ if not isinstance(text,str):return text
711
+ text=re.sub(r'<think\b[^>]*>.*?</think\s*>','',text,flags=re.I|re.S)
712
+ # Some OpenAI-compatible responses omit the opening tag but retain its close.
713
+ closes=list(re.finditer(r'</think\s*>',text,re.I))
714
+ if closes:text=text[closes[-1].end():]
715
+ text=re.split(r'<think\b[^>]*>',text,maxsplit=1,flags=re.I)[0]
716
+ return text.strip()
717
+
718
+
719
+ def visible_completion(result):
720
+ """Keep final text and tool calls; reasoning never enters the public stream."""
721
+ choices=result.get('choices')
722
+ if not isinstance(choices,list) or not choices:raise ValueError('The model returned no completion choices')
723
+ out=dict(result);out['choices']=[]
724
+ for choice in choices:
725
+ if not isinstance(choice,dict) or not isinstance(choice.get('message'),dict):raise ValueError('The model returned no message')
726
+ message=dict(choice['message'])
727
+ for key in ('reasoning','reasoning_content','thinking'):message.pop(key,None)
728
+ if isinstance(message.get('content'),str):message['content']=visible_answer(message['content'])
729
+ if not message.get('content') and not message.get('tool_calls'):
730
+ raise ValueError('The model did not return a complete answer or tool call')
731
+ out['choices'].append(dict(choice,message=message))
732
+ return out
733
+
734
+
735
  @local_router.post('/local-llm/v1/chat/completions')
736
  async def local_completion(request:Request):
737
  if not request.client or request.client.host not in ('127.0.0.1','::1'):raise HTTPException(403,'Loopback only')
 
743
  try:result=await asyncio.to_thread(dispatch,'llm',dict(data,model=MODEL,stream=False),strict=True)
744
  except GPUQwenUnavailable as exc:
745
  return JSONResponse({'error':{'message':str(exc),'type':'gpu_qwen_unavailable','code':'GPU_QWEN_UNAVAILABLE','reason':exc.reason}},status_code=503)
746
+ try:result=visible_completion(result)
747
+ except ValueError:
748
+ return JSONResponse({'error':{'message':'๋ชจ๋ธ์ด ์™„์„ฑ๋œ ๋‹ต๋ณ€์„ ๋ฐ˜ํ™˜ํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค. ๋‹ค์‹œ ์‹œ๋„ํ•ด ์ฃผ์„ธ์š”.','code':'INCOMPLETE_MODEL_OUTPUT'}},status_code=502)
749
  if not data.get('stream'):return result
750
  choice=result['choices'][0];message=choice['message'];delta=dict(message)
751
  if message.get('tool_calls'):delta['tool_calls']=[dict(t,index=i) for i,t in enumerate(message['tool_calls'])]
requirements.txt CHANGED
@@ -5,10 +5,11 @@ tabicl[forecast]==2.2.0
5
  torch==2.14.0
6
  uvicorn==0.52.4
7
 
8
- openai-agents>=0.22,<0.23
9
- openai>=3,<4
10
  duckdb>=1.5.5,<2
11
  pandas>=2.2,<3
12
  pyyaml>=6,<7
13
- rdflib>=7,<8
 
14
  cryptography>=42,<49
 
5
  torch==2.14.0
6
  uvicorn==0.52.4
7
 
8
+ openai-agents==0.22.3
9
+ openai==3.16.2
10
  duckdb>=1.5.5,<2
11
  pandas>=2.2,<3
12
  pyyaml>=6,<7
13
+ rdflib==7.6.0
14
+ pyparsing==3.3.3
15
  cryptography>=42,<49
web/agent-ui.js CHANGED
@@ -11,6 +11,7 @@ export function agentSurface(id,event){
11
  else if(event.type==='approval'){
12
  const pending=event.actions?.[0];if(pending?.tool!=='execute_features')throw new Error('์Šน์ธํ•  ๋„๊ตฌ๋ฅผ ํ™•์ธํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.');
13
  items=[text('title','์ด ๊ธฐ์ค€์œผ๋กœ ์œ„ํ—˜๋„๋ฅผ ๊ณ„์‚ฐํ• ๊นŒ์š”?','heading'),text('scope','ํ•™์Šต ๋ฐ์ดํ„ฐ 96ํ–‰์„ ๋ฌธ๋งฅ์œผ๋กœ TabICL์ด ๋ฐ”๋กœ ๊ณ„์‚ฐํ•ฉ๋‹ˆ๋‹ค(๋ชจ๋ธ ํ•™์Šต ์—†์Œ). ๋นผ๊ฑฐ๋‚˜ ๋ฐ”๊ฟ€ ๊ธฐ์ค€์€ ์ฑ„ํŒ…์œผ๋กœ ์•Œ๋ ค ์ฃผ์„ธ์š”.','muted'),{id:'actions',component:'Actions',buttons:[{id:'approve',label:'์ด๋Œ€๋กœ ์˜ˆ์ธก',primary:true,action:{event:{name:'approve_prediction'}}},{id:'edit',label:'๊ธฐ์ค€ ๋ฐ”๊พธ๊ธฐ',primary:false,action:{event:{name:'edit_question'}}}]}];
 
14
  model={selected_features:[0]};
15
  }else if(event.tool==='describe_data'){
16
  if(!Array.isArray(d.datasets))throw new Error('๋ฐ์ดํ„ฐ ์‚ฌ์ „ ์‘๋‹ต์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค.');
@@ -29,18 +30,23 @@ export function agentSurface(id,event){
29
  items=[table('ontology','์˜จํ†จ๋กœ์ง€ ์กฐํšŒ',keys,rows.map(r=>keys.map(k=>r[k]??'')))];
30
  }else if(event.tool==='propose_features'){
31
  if(!Array.isArray(d.programs))throw new Error('ํ”ผ์ฒ˜ ์ œ์•ˆ ์‘๋‹ต์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค.');
32
- items=[text('label','์˜ˆ์ธก์— ์“ธ ๊ธฐ์ค€','label'),text('programs',d.programs.map(p=>`- **${korean(p.expr,d.columns)}** โ€” ${p.rationale}`).join('\n'))];
33
  if(d.rejected?.length)items.push(text('rejected',`์งˆ๋ฌธ๊ณผ ๋งž์ง€ ์•Š๊ฑฐ๋‚˜ ๊ฒ€์ฆ์„ ํ†ต๊ณผํ•˜์ง€ ๋ชปํ•œ ์ œ์•ˆ ${d.rejected.length}๊ฐœ๋Š” ๋บ์Šต๋‹ˆ๋‹ค.`,'muted'));
34
  }else if(event.tool==='execute_features')return [];
35
  else if(event.tool==='predict'){
36
  if(d.model?.output!=='real_inference'||!Array.isArray(d.predictions)||d.predictions.some(r=>!Number.isFinite(r.probability)||r.probability<0||r.probability>1))throw new Error('์‹ค์ œ ์˜ˆ์ธก ๊ฒฐ๊ณผ๋ฅผ ํ™•์ธํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.');
37
  const ui=SCENARIO_UI[d.task]||{name:'์˜ˆ์ธก',title:'์œ„ํ—˜ ์ˆœ์œ„',next:''};const top=d.predictions[0];
38
- const signals=(d.top_evidence?.features||[]).map(f=>({feature:korean(f.expr,d.columns),value:f.value}));
 
39
  items=[text('label',`${ui.name} ์˜ˆ์ธก ยท ํ•ฉ์„ฑ ๋ฐ์ดํ„ฐ`,'label'),text('answer',`${top.subject}๋ถ€ํ„ฐ ํ™•์ธํ•˜์„ธ์š”.`,'heading'),{id:'metric',component:'Metric',label:`${top.subject}์˜ ์˜ˆ์ธก ํ™•๋ฅ `,value:{path:'/top'}},{id:'ranking',component:'Ranking',title:ui.title,items:{path:'/predictions'}}];
40
  if(ui.next)items.push(text('next',ui.next,'callout'));
41
  items.push({id:'evidence',component:'Evidence',title:`๊ทผ๊ฑฐ์™€ ๋ชจ๋ธ ๋ณด๊ธฐ ยท ${top.subject} ์ž…๋ ฅ๊ฐ’`,features:{path:'/features'},signals:{path:'/signals'},model:{path:'/model'},timing:{path:'/timing'}});
42
  model={top:top.probability,predictions:d.predictions,features:d.programs.map(p=>korean(p.expr,d.columns)),signals,model:d.model,timing:`๋ณด๋ฅ˜ ๊ฒ€์ฆ AUROC ${d.validation.auc.toFixed(3)} (์ƒ์ˆ˜ ๊ธฐ์ค€์„  0.5) ยท ๊ณ„์‚ฐ ${d.seconds}์ดˆ`};
43
- }else if(event.tool==='explain')items=[table('evidence',`${d.subject}์˜ ์ž…๋ ฅ๊ฐ’`,['๊ธฐ์ค€','์ž…๋ ฅ๊ฐ’'],(d.features||[]).map(f=>[korean(f.expr,{}),Number(f.value).toFixed(2)])),text('caveat','๊ด€์ธก ์ž…๋ ฅ๊ฐ’์ด๋ฉฐ ์ธ๊ณผ๊ด€๊ณ„๋‚˜ ๋ณ€์ˆ˜ ์ค‘์š”๋„๋ฅผ ๋œปํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.','muted')];
 
 
 
 
44
  else if(event.tool==='publish_results'){
45
  if(!/^\/demo#report=[0-9a-f]{32}$/.test(d.dashboard_url))throw new Error('๊ฒฐ๊ณผ ๋งํฌ๊ฐ€ ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค.');
46
  items=[{id:'report',component:'ReportLink',label:'๊ณต์œ ์šฉ ๊ฒฐ๊ณผ ํŽ˜์ด์ง€ ์—ด๊ธฐ โ†—',url:d.dashboard_url}];
 
11
  else if(event.type==='approval'){
12
  const pending=event.actions?.[0];if(pending?.tool!=='execute_features')throw new Error('์Šน์ธํ•  ๋„๊ตฌ๋ฅผ ํ™•์ธํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.');
13
  items=[text('title','์ด ๊ธฐ์ค€์œผ๋กœ ์œ„ํ—˜๋„๋ฅผ ๊ณ„์‚ฐํ• ๊นŒ์š”?','heading'),text('scope','ํ•™์Šต ๋ฐ์ดํ„ฐ 96ํ–‰์„ ๋ฌธ๋งฅ์œผ๋กœ TabICL์ด ๋ฐ”๋กœ ๊ณ„์‚ฐํ•ฉ๋‹ˆ๋‹ค(๋ชจ๋ธ ํ•™์Šต ์—†์Œ). ๋นผ๊ฑฐ๋‚˜ ๋ฐ”๊ฟ€ ๊ธฐ์ค€์€ ์ฑ„ํŒ…์œผ๋กœ ์•Œ๋ ค ์ฃผ์„ธ์š”.','muted'),{id:'actions',component:'Actions',buttons:[{id:'approve',label:'์ด๋Œ€๋กœ ์˜ˆ์ธก',primary:true,action:{event:{name:'approve_prediction'}}},{id:'edit',label:'๊ธฐ์ค€ ๋ฐ”๊พธ๊ธฐ',primary:false,action:{event:{name:'edit_question'}}}]}];
14
+ if(pending.programs?.length)items.splice(1,0,text('selected','์‹คํ–‰ํ•  ๊ธฐ์ค€: '+pending.programs.map(p=>korean(p.expr,pending.columns)).join(' ยท ')));
15
  model={selected_features:[0]};
16
  }else if(event.tool==='describe_data'){
17
  if(!Array.isArray(d.datasets))throw new Error('๋ฐ์ดํ„ฐ ์‚ฌ์ „ ์‘๋‹ต์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค.');
 
30
  items=[table('ontology','์˜จํ†จ๋กœ์ง€ ์กฐํšŒ',keys,rows.map(r=>keys.map(k=>r[k]??'')))];
31
  }else if(event.tool==='propose_features'){
32
  if(!Array.isArray(d.programs))throw new Error('ํ”ผ์ฒ˜ ์ œ์•ˆ ์‘๋‹ต์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค.');
33
+ items=[text('label','์˜ˆ์ธก์— ์“ธ ๊ธฐ์ค€','label'),text('programs',d.programs.map(p=>`- **${korean(p.expr,d.columns)}**${d.definitions?.[p.name]?` โ€” ${d.definitions[p.name]}`:''}`).join('\n'))];
34
  if(d.rejected?.length)items.push(text('rejected',`์งˆ๋ฌธ๊ณผ ๋งž์ง€ ์•Š๊ฑฐ๋‚˜ ๊ฒ€์ฆ์„ ํ†ต๊ณผํ•˜์ง€ ๋ชปํ•œ ์ œ์•ˆ ${d.rejected.length}๊ฐœ๋Š” ๋บ์Šต๋‹ˆ๋‹ค.`,'muted'));
35
  }else if(event.tool==='execute_features')return [];
36
  else if(event.tool==='predict'){
37
  if(d.model?.output!=='real_inference'||!Array.isArray(d.predictions)||d.predictions.some(r=>!Number.isFinite(r.probability)||r.probability<0||r.probability>1))throw new Error('์‹ค์ œ ์˜ˆ์ธก ๊ฒฐ๊ณผ๋ฅผ ํ™•์ธํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.');
38
  const ui=SCENARIO_UI[d.task]||{name:'์˜ˆ์ธก',title:'์œ„ํ—˜ ์ˆœ์œ„',next:''};const top=d.predictions[0];
39
+ const inputs=d.top_evidence?.input_values||[];
40
+ const signals=[...inputs.map(v=>({feature:v.column,value:v.value})),...(d.top_evidence?.features||[]).filter(f=>!inputs.some(v=>f.expr===v.column_code)).map(f=>({feature:f.display_expr||korean(f.expr,d.columns),value:f.value}))];
41
  items=[text('label',`${ui.name} ์˜ˆ์ธก ยท ํ•ฉ์„ฑ ๋ฐ์ดํ„ฐ`,'label'),text('answer',`${top.subject}๋ถ€ํ„ฐ ํ™•์ธํ•˜์„ธ์š”.`,'heading'),{id:'metric',component:'Metric',label:`${top.subject}์˜ ์˜ˆ์ธก ํ™•๋ฅ `,value:{path:'/top'}},{id:'ranking',component:'Ranking',title:ui.title,items:{path:'/predictions'}}];
42
  if(ui.next)items.push(text('next',ui.next,'callout'));
43
  items.push({id:'evidence',component:'Evidence',title:`๊ทผ๊ฑฐ์™€ ๋ชจ๋ธ ๋ณด๊ธฐ ยท ${top.subject} ์ž…๋ ฅ๊ฐ’`,features:{path:'/features'},signals:{path:'/signals'},model:{path:'/model'},timing:{path:'/timing'}});
44
  model={top:top.probability,predictions:d.predictions,features:d.programs.map(p=>korean(p.expr,d.columns)),signals,model:d.model,timing:`๋ณด๋ฅ˜ ๊ฒ€์ฆ AUROC ${d.validation.auc.toFixed(3)} (์ƒ์ˆ˜ ๊ธฐ์ค€์„  0.5) ยท ๊ณ„์‚ฐ ${d.seconds}์ดˆ`};
45
+ }else if(event.tool==='explain'){
46
+ items=[];
47
+ if(d.input_values?.length)items.push(table('source-inputs',`${d.subject}์˜ ์›๋ณธ ์ž…๋ ฅ`,['์ปฌ๋Ÿผ','์ž…๋ ฅ๊ฐ’'],d.input_values.map(v=>[v.column,Number(v.value).toFixed(2)])));
48
+ items.push(table('evidence',`${d.subject}์˜ ๊ณ„์‚ฐ๋œ ๊ธฐ์ค€๊ฐ’`,['๊ธฐ์ค€','๊ณ„์‚ฐ๊ฐ’'],(d.features||[]).map(f=>[f.display_expr||korean(f.expr,d.columns),Number(f.value).toFixed(2)])),text('caveat','๊ด€์ธก ์ž…๋ ฅ๊ฐ’์ด๋ฉฐ ์ธ๊ณผ๊ด€๊ณ„๋‚˜ ๋ณ€์ˆ˜ ์ค‘์š”๋„๋ฅผ ๋œปํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.','muted'));
49
+ }
50
  else if(event.tool==='publish_results'){
51
  if(!/^\/demo#report=[0-9a-f]{32}$/.test(d.dashboard_url))throw new Error('๊ฒฐ๊ณผ ๋งํฌ๊ฐ€ ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค.');
52
  items=[{id:'report',component:'ReportLink',label:'๊ณต์œ ์šฉ ๊ฒฐ๊ณผ ํŽ˜์ด์ง€ ์—ด๊ธฐ โ†—',url:d.dashboard_url}];
web/demo-chat.js CHANGED
@@ -13,14 +13,14 @@ function lock(busy){form.toggleAttribute('aria-busy',busy);form.querySelector('b
13
  function newChat(){generation++;active?.abort();active=null;sid=null;turns.forEach(t=>t.renderer.setLocked(true));turns.length=0;conversation.replaceChildren();$('#welcome').hidden=false;question.value='';history.replaceState(null,'','#start');show('start');lock(false);question.focus();announce('์ƒˆ ๋Œ€ํ™”๋ฅผ ์‹œ์ž‘ํ–ˆ์Šต๋‹ˆ๋‹ค.');}
14
  $('#new-chat').onclick=newChat;$('#new-chat-mobile').onclick=newChat;
15
  function makeTurn(text){$('#welcome').hidden=true;show('start');const article=node('article','chat-turn');if(text)article.append(node('p','user-message',text));const label=node('div','assistant-label');label.append(node('b','','O'),node('span','','ONTIC'));const host=node('div','assistant-content');article.append(label,host);conversation.append(article);const turn={node:article,host,text,renderer:new A2UIRenderer(host,event=>action(turn,event)),pending:false,answer:null};turns.push(turn);article.scrollIntoView({behavior:'smooth',block:'start'});return turn;}
16
- function render(turn,event,id){const messages=agentSurface(id,event);messages.forEach(m=>turn.renderer.consume(m));}
17
  function action(turn,event){if(active||turn!==turns.at(-1)||!turn.pending)return;
18
  if(event.action.name==='approve_prediction'){turn.pending=false;turn.renderer.setLocked(true);run({sid,approve:true},turn);}
19
  else{question.focus();question.placeholder='์ œ์™ธํ•  ํ”ผ์ฒ˜๋‚˜ ๋ฐ”๊ฟ€ ์กฐ๊ฑด์„ ์•Œ๋ ค ์ฃผ์„ธ์š”';announce('๋ฐ”๊ฟ€ ์กฐ๊ฑด์„ ์ž…๋ ฅํ•˜๋ฉด ์—์ด์ „ํŠธ๊ฐ€ ์ œ์•ˆ์„ ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค.');}
20
  }
21
  function failure(turn,err){const box=node('div','chat-error');box.setAttribute('role','alert');box.append(node('p','',err.message));if(err.request_id)box.append(node('p','fineprint',`์š”์ฒญ ID: ${err.request_id}`));turn.host.append(box);announce(err.message);}
22
  async function run(payload,turn){
23
- const mine=generation,controller=new AbortController();active=controller;turn.answer=null;lock(true);
24
  const statusId='agent-status-'+(++counter);surface(statusId,[{id:'root',component:'Column',children:['status']},{id:'status',component:'Text',tone:'status',text:'๋ฐ์ดํ„ฐ์™€ ๋Œ€ํ™” ์ด๋ ฅ์„ ํ™•์ธํ•˜๊ณ  ์žˆ์–ด์š”.'}],{}).forEach(m=>turn.renderer.consume(m));
25
  const status=text=>{if(turn.renderer.surfaces.has(statusId))turn.renderer.consume({version:'v0.9',updateComponents:{surfaceId:statusId,components:[{id:'root',component:'Column',children:['status']},{id:'status',component:'Text',tone:'status',text}]}});announce(text);};
26
  let timeout;const resetTimer=()=>{clearTimeout(timeout);timeout=setTimeout(()=>controller.abort(),150000);};resetTimer();let terminal=false;
@@ -34,9 +34,9 @@ async function run(payload,turn){
34
  else if(e.type==='action'){status(e.summary||'์—์ด์ „ํŠธ๊ฐ€ ๋„๊ตฌ๋ฅผ ์‹คํ–‰ํ•˜๊ณ  ์žˆ์–ด์š”.');}
35
  else if(e.type==='observation'){
36
  if(e.data?.error){status('๋„๊ตฌ ์‘๋‹ต์„ ํ™•์ธํ•˜๊ณ  ์ˆ˜์ •ํ•˜๊ณ  ์žˆ์–ด์š”.');return;}
37
- if(e.tool==='predict')turn.topRow=e.data?.predictions?.[0]?.row;
38
- if(e.tool==='explain'&&e.data?.row===turn.topRow)return; // already inside the result card's evidence
39
- const observationId='agent-tool-'+(++counter);render(turn,e,observationId);
40
  if(e.tool==='predict'){const n=turn.renderer.surfaces.get(observationId)?.node;if(n)window.scrollTo({top:n.getBoundingClientRect().top+scrollY-80,left:0,behavior:'smooth'});} // the answer, not the approval card, should be on screen
41
  if(['describe_data','query_ontology','explain'].includes(e.tool)){const rendered=turn.renderer.surfaces.get(observationId)?.node;if(rendered){const details=node('details','tool-observation');details.append(node('summary','',e.tool==='explain'?'์ž…๋ ฅ ๊ทผ๊ฑฐ ๋ณด๊ธฐ':'์กฐํšŒํ•œ ๋ฐ์ดํ„ฐยท์ปฌ๋Ÿผ ๋ณด๊ธฐ'));rendered.before(details);details.append(rendered);}}
42
  }else if(e.type==='token'){
 
13
  function newChat(){generation++;active?.abort();active=null;sid=null;turns.forEach(t=>t.renderer.setLocked(true));turns.length=0;conversation.replaceChildren();$('#welcome').hidden=false;question.value='';history.replaceState(null,'','#start');show('start');lock(false);question.focus();announce('์ƒˆ ๋Œ€ํ™”๋ฅผ ์‹œ์ž‘ํ–ˆ์Šต๋‹ˆ๋‹ค.');}
14
  $('#new-chat').onclick=newChat;$('#new-chat-mobile').onclick=newChat;
15
  function makeTurn(text){$('#welcome').hidden=true;show('start');const article=node('article','chat-turn');if(text)article.append(node('p','user-message',text));const label=node('div','assistant-label');label.append(node('b','','O'),node('span','','ONTIC'));const host=node('div','assistant-content');article.append(label,host);conversation.append(article);const turn={node:article,host,text,renderer:new A2UIRenderer(host,event=>action(turn,event)),pending:false,answer:null};turns.push(turn);article.scrollIntoView({behavior:'smooth',block:'start'});return turn;}
16
+ function render(turn,event,id){const messages=agentSurface(id,event);messages.forEach(m=>{if(m.createSurface&&turn.renderer.surfaces.has(id))return;turn.renderer.consume(m);});}
17
  function action(turn,event){if(active||turn!==turns.at(-1)||!turn.pending)return;
18
  if(event.action.name==='approve_prediction'){turn.pending=false;turn.renderer.setLocked(true);run({sid,approve:true},turn);}
19
  else{question.focus();question.placeholder='์ œ์™ธํ•  ํ”ผ์ฒ˜๋‚˜ ๋ฐ”๊ฟ€ ์กฐ๊ฑด์„ ์•Œ๋ ค ์ฃผ์„ธ์š”';announce('๋ฐ”๊ฟ€ ์กฐ๊ฑด์„ ์ž…๋ ฅํ•˜๋ฉด ์—์ด์ „ํŠธ๊ฐ€ ์ œ์•ˆ์„ ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค.');}
20
  }
21
  function failure(turn,err){const box=node('div','chat-error');box.setAttribute('role','alert');box.append(node('p','',err.message));if(err.request_id)box.append(node('p','fineprint',`์š”์ฒญ ID: ${err.request_id}`));turn.host.append(box);announce(err.message);}
22
  async function run(payload,turn){
23
+ const mine=generation,controller=new AbortController();active=controller;turn.answer=null;turn.proposalSurfaceId=null;lock(true);
24
  const statusId='agent-status-'+(++counter);surface(statusId,[{id:'root',component:'Column',children:['status']},{id:'status',component:'Text',tone:'status',text:'๋ฐ์ดํ„ฐ์™€ ๋Œ€ํ™” ์ด๋ ฅ์„ ํ™•์ธํ•˜๊ณ  ์žˆ์–ด์š”.'}],{}).forEach(m=>turn.renderer.consume(m));
25
  const status=text=>{if(turn.renderer.surfaces.has(statusId))turn.renderer.consume({version:'v0.9',updateComponents:{surfaceId:statusId,components:[{id:'root',component:'Column',children:['status']},{id:'status',component:'Text',tone:'status',text}]}});announce(text);};
26
  let timeout;const resetTimer=()=>{clearTimeout(timeout);timeout=setTimeout(()=>controller.abort(),150000);};resetTimer();let terminal=false;
 
34
  else if(e.type==='action'){status(e.summary||'์—์ด์ „ํŠธ๊ฐ€ ๋„๊ตฌ๋ฅผ ์‹คํ–‰ํ•˜๊ณ  ์žˆ์–ด์š”.');}
35
  else if(e.type==='observation'){
36
  if(e.data?.error){status('๋„๊ตฌ ์‘๋‹ต์„ ํ™•์ธํ•˜๊ณ  ์ˆ˜์ •ํ•˜๊ณ  ์žˆ์–ด์š”.');return;}
37
+ if(e.tool==='predict'){turn.topRow=e.data?.predictions?.[0]?.row;turn.hasTopEvidence=Boolean(e.data?.top_evidence?.features?.length);}
38
+ if(e.tool==='explain'&&e.data?.row===turn.topRow&&turn.hasTopEvidence)return; // suppress only evidence actually present in the result card
39
+ const observationId=e.tool==='propose_features'?(turn.proposalSurfaceId??=( 'agent-proposal-'+(++counter))):'agent-tool-'+(++counter);render(turn,e,observationId);
40
  if(e.tool==='predict'){const n=turn.renderer.surfaces.get(observationId)?.node;if(n)window.scrollTo({top:n.getBoundingClientRect().top+scrollY-80,left:0,behavior:'smooth'});} // the answer, not the approval card, should be on screen
41
  if(['describe_data','query_ontology','explain'].includes(e.tool)){const rendered=turn.renderer.surfaces.get(observationId)?.node;if(rendered){const details=node('details','tool-observation');details.append(node('summary','',e.tool==='explain'?'์ž…๋ ฅ ๊ทผ๊ฑฐ ๋ณด๊ธฐ':'์กฐํšŒํ•œ ๋ฐ์ดํ„ฐยท์ปฌ๋Ÿผ ๋ณด๊ธฐ'));rendered.before(details);details.append(rendered);}}
42
  }else if(e.type==='token'){