Patryk Studzinski commited on
Commit
5fabfb8
1 Parent(s): 42e3538

adding infill

Browse files
app/domains/cars/config.py CHANGED
@@ -1,10 +1,11 @@
1
  from app.domains.cars.schemas import CarData
2
- from app.domains.cars.prompts import create_prompt
3
 
4
  # Domain-specific configuration for 'cars'
5
  domain_config = {
6
  "schema": CarData,
7
  "create_prompt": create_prompt,
 
8
  "mcp_rules": {
9
  "preprocessor": {
10
  # Add any car-specific preprocessing rules here
 
1
  from app.domains.cars.schemas import CarData
2
+ from app.domains.cars.prompts import create_prompt, create_infill_prompt
3
 
4
  # Domain-specific configuration for 'cars'
5
  domain_config = {
6
  "schema": CarData,
7
  "create_prompt": create_prompt,
8
+ "create_infill_prompt": create_infill_prompt,
9
  "mcp_rules": {
10
  "preprocessor": {
11
  # Add any car-specific preprocessing rules here
app/domains/cars/prompts.py CHANGED
@@ -1,4 +1,5 @@
1
  from app.domains.cars.schemas import CarData
 
2
 
3
  def create_prompt(car_data: CarData) -> list[dict]:
4
  """
@@ -28,3 +29,56 @@ Na podstawie poni偶szych danych, utw贸rz kr贸tki, atrakcyjny opis marketingowy t
28
  """
29
  }
30
  ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from app.domains.cars.schemas import CarData
2
+ from app.schemas.schemas import InfillOptions
3
 
4
  def create_prompt(car_data: CarData) -> list[dict]:
5
  """
 
29
  """
30
  }
31
  ]
32
+
33
+
34
+ def create_infill_prompt(text_with_gaps: str, options: InfillOptions) -> list[dict]:
35
+ """
36
+ Creates the chat prompt for gap-filling in car ads.
37
+
38
+ The LLM must return strict JSON with filled text and per-gap choices.
39
+
40
+ Args:
41
+ text_with_gaps: Ad text with [GAP:n] markers
42
+ options: InfillOptions with language, top_n_per_gap, etc.
43
+
44
+ Returns:
45
+ Chat messages for the LLM
46
+ """
47
+ lang_instruction = "po polsku" if options.language == "pl" else "in English"
48
+
49
+ system_content = f"""Jeste艣 ekspertem od uzupe艂niania tekst贸w og艂osze艅 samochodowych.
50
+
51
+ ZADANIE:
52
+ Uzupe艂nij luki oznaczone jako [GAP:n] najbardziej naturalnymi s艂owami {lang_instruction}.
53
+ Ka偶da luka powinna by膰 uzupe艂niona s艂owem lub kr贸tk膮 fraz膮 (1-4 s艂owa).
54
+
55
+ ZASADY:
56
+ 1. NIE zmieniaj 偶adnego tekstu poza lukami [GAP:n]
57
+ 2. Uzupe艂nienia musz膮 pasowa膰 kontekstowo i gramatycznie
58
+ 3. U偶ywaj s艂ownictwa typowego dla og艂osze艅 motoryzacyjnych
59
+ 4. Dla ka偶dej luki podaj {options.top_n_per_gap} alternatywnych propozycji
60
+
61
+ WYMAGANY FORMAT ODPOWIEDZI (tylko JSON, bez komentarzy):
62
+ {{
63
+ "filled_text": "Pe艂ny tekst z uzupe艂nionymi lukami",
64
+ "gaps": [
65
+ {{
66
+ "index": 1,
67
+ "marker": "[GAP:1]",
68
+ "choice": "wybrane_s艂owo",
69
+ "alternatives": ["alt1", "alt2"]
70
+ }}
71
+ ]
72
+ }}"""
73
+
74
+ user_content = f"""Uzupe艂nij luki w poni偶szym og艂oszeniu:
75
+
76
+ {text_with_gaps}
77
+
78
+ Odpowiedz TYLKO poprawnym JSON zgodnym z podanym formatem."""
79
+
80
+ return [
81
+ {"role": "system", "content": system_content},
82
+ {"role": "user", "content": user_content}
83
+ ]
84
+
app/logic/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Logic module for MCP processing and utilities
app/logic/infill_utils.py ADDED
@@ -0,0 +1,233 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Infill Utilities for Batch Gap-Filling
3
+
4
+ Handles gap detection, JSON parsing from LLM output, and text reconstruction.
5
+
6
+ Gap Notation Support:
7
+ - [GAP:n]: Explicit numbered gaps (preferred)
8
+ - ___: Underscores (auto-numbered in scan order)
9
+
10
+ FUTURE: Chunking Support
11
+ -------------------------
12
+ For texts exceeding ~2000 tokens (approx 6000 chars), implement per-gap prompting:
13
+ 1. Split text into chunks preserving gap context (卤150 tokens around each gap)
14
+ 2. Process each gap individually with left/right context
15
+ 3. Merge results back into full text
16
+ 4. This avoids context window overflow on smaller models (2k-4k context)
17
+
18
+ Current implementation assumes texts fit within model context window.
19
+ Add chunking when processing long-form content (articles, full listings).
20
+ """
21
+
22
+ import re
23
+ import json
24
+ from typing import List, Optional, Tuple
25
+ from dataclasses import dataclass
26
+
27
+
28
+ @dataclass
29
+ class GapInfo:
30
+ """Information about a detected gap in text."""
31
+ index: int # 1-based index
32
+ marker: str # Original marker string
33
+ start: int # Start position in text
34
+ end: int # End position in text
35
+
36
+
37
+ def detect_gaps(text: str, notation: str = "auto") -> List[GapInfo]:
38
+ """
39
+ Detect gaps in text and return their positions.
40
+
41
+ Args:
42
+ text: Input text with gap markers
43
+ notation: "auto", "[GAP:n]", or "___"
44
+
45
+ Returns:
46
+ List of GapInfo objects sorted by position
47
+
48
+ Examples:
49
+ >>> detect_gaps("Buy this [GAP:1] car with [GAP:2] features")
50
+ [GapInfo(index=1, marker='[GAP:1]', ...), GapInfo(index=2, marker='[GAP:2]', ...)]
51
+
52
+ >>> detect_gaps("Buy this ___ car with ___ features")
53
+ [GapInfo(index=1, marker='___', ...), GapInfo(index=2, marker='___', ...)]
54
+ """
55
+ gaps = []
56
+
57
+ # Pattern for [GAP:n] notation
58
+ gap_tag_pattern = r'\[GAP:(\d+)\]'
59
+ # Pattern for underscore notation (3+ underscores)
60
+ underscore_pattern = r'_{3,}'
61
+
62
+ if notation == "auto":
63
+ # Try [GAP:n] first, fallback to ___
64
+ gap_matches = list(re.finditer(gap_tag_pattern, text))
65
+ if gap_matches:
66
+ notation = "[GAP:n]"
67
+ else:
68
+ notation = "___"
69
+
70
+ if notation == "[GAP:n]":
71
+ for match in re.finditer(gap_tag_pattern, text):
72
+ gaps.append(GapInfo(
73
+ index=int(match.group(1)),
74
+ marker=match.group(0),
75
+ start=match.start(),
76
+ end=match.end()
77
+ ))
78
+ else: # "___"
79
+ for i, match in enumerate(re.finditer(underscore_pattern, text), start=1):
80
+ gaps.append(GapInfo(
81
+ index=i,
82
+ marker=match.group(0),
83
+ start=match.start(),
84
+ end=match.end()
85
+ ))
86
+
87
+ # Sort by position (should already be, but ensure)
88
+ gaps.sort(key=lambda g: g.start)
89
+ return gaps
90
+
91
+
92
+ def parse_infill_json(raw_output: str) -> Optional[dict]:
93
+ """
94
+ Extract and parse JSON from LLM output.
95
+
96
+ Handles common LLM quirks:
97
+ - JSON wrapped in markdown code blocks
98
+ - Leading/trailing text before/after JSON
99
+ - Minor formatting issues
100
+
101
+ Args:
102
+ raw_output: Raw text from LLM
103
+
104
+ Returns:
105
+ Parsed dict with 'filled_text' and 'gaps' keys, or None if parsing fails
106
+
107
+ Expected JSON format:
108
+ {
109
+ "filled_text": "Complete text with gaps filled",
110
+ "gaps": [
111
+ {"index": 1, "marker": "[GAP:1]", "choice": "word", "alternatives": ["alt1", "alt2"]}
112
+ ]
113
+ }
114
+ """
115
+ if not raw_output:
116
+ return None
117
+
118
+ # Try to extract JSON from markdown code blocks
119
+ json_block_pattern = r'```(?:json)?\s*([\s\S]*?)\s*```'
120
+ match = re.search(json_block_pattern, raw_output)
121
+ if match:
122
+ raw_output = match.group(1)
123
+
124
+ # Try to find JSON object boundaries
125
+ # Look for the outermost { }
126
+ start_idx = raw_output.find('{')
127
+ if start_idx == -1:
128
+ return None
129
+
130
+ # Find matching closing brace
131
+ depth = 0
132
+ end_idx = -1
133
+ for i, char in enumerate(raw_output[start_idx:], start=start_idx):
134
+ if char == '{':
135
+ depth += 1
136
+ elif char == '}':
137
+ depth -= 1
138
+ if depth == 0:
139
+ end_idx = i + 1
140
+ break
141
+
142
+ if end_idx == -1:
143
+ return None
144
+
145
+ json_str = raw_output[start_idx:end_idx]
146
+
147
+ try:
148
+ parsed = json.loads(json_str)
149
+
150
+ # Validate required fields
151
+ if 'filled_text' not in parsed and 'gaps' not in parsed:
152
+ return None
153
+
154
+ return parsed
155
+ except json.JSONDecodeError:
156
+ return None
157
+
158
+
159
+ def apply_fills(original_text: str, gaps: List[GapInfo], fills: dict) -> str:
160
+ """
161
+ Apply gap fills to original text.
162
+
163
+ Uses fills from parsed JSON, replacing markers with chosen words.
164
+ This is a fallback when LLM's 'filled_text' might be corrupted.
165
+
166
+ Args:
167
+ original_text: Original text with gap markers
168
+ gaps: Detected gaps from detect_gaps()
169
+ fills: Dict mapping gap index to fill choice
170
+ e.g., {1: "excellent", 2: "powerful"}
171
+
172
+ Returns:
173
+ Text with gaps replaced by fill choices
174
+ """
175
+ if not gaps or not fills:
176
+ return original_text
177
+
178
+ # Process from end to start to preserve positions
179
+ result = original_text
180
+ for gap in reversed(gaps):
181
+ if gap.index in fills:
182
+ result = result[:gap.start] + fills[gap.index] + result[gap.end:]
183
+
184
+ return result
185
+
186
+
187
+ def build_fills_dict(gaps_list: List[dict]) -> dict:
188
+ """
189
+ Convert gaps list from JSON to fills dict.
190
+
191
+ Args:
192
+ gaps_list: List of gap dicts from parsed JSON
193
+ [{"index": 1, "choice": "word"}, ...]
194
+
195
+ Returns:
196
+ Dict mapping index to choice: {1: "word", ...}
197
+ """
198
+ fills = {}
199
+ for gap in gaps_list:
200
+ if 'index' in gap and 'choice' in gap:
201
+ fills[gap['index']] = gap['choice']
202
+ return fills
203
+
204
+
205
+ def normalize_gaps_to_tagged(text: str) -> Tuple[str, List[GapInfo]]:
206
+ """
207
+ Normalize any gap notation to [GAP:n] format.
208
+
209
+ Useful for standardizing input before processing.
210
+
211
+ Args:
212
+ text: Text with any gap notation
213
+
214
+ Returns:
215
+ Tuple of (normalized_text, gaps)
216
+ """
217
+ gaps = detect_gaps(text, "auto")
218
+
219
+ if not gaps:
220
+ return text, []
221
+
222
+ # If already [GAP:n], return as-is
223
+ if gaps[0].marker.startswith('[GAP:'):
224
+ return text, gaps
225
+
226
+ # Convert ___ to [GAP:n]
227
+ result = text
228
+ for gap in reversed(gaps):
229
+ new_marker = f"[GAP:{gap.index}]"
230
+ result = result[:gap.start] + new_marker + result[gap.end:]
231
+
232
+ # Re-detect with new positions
233
+ return result, detect_gaps(result, "[GAP:n]")
app/main.py CHANGED
@@ -14,6 +14,20 @@ from app.schemas.schemas import (
14
  CompareResponse,
15
  ModelResult,
16
  ModelInfo,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  )
18
  from app.auth.placeholder_auth import get_authenticated_user
19
 
@@ -245,4 +259,210 @@ async def get_user_info(user: dict = Depends(get_authenticated_user)):
245
  "user_id": user['user_id'],
246
  "email": user['email'],
247
  "name": user.get('name', 'Unknown')
248
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  CompareResponse,
15
  ModelResult,
16
  ModelInfo,
17
+ InfillRequest,
18
+ InfillResponse,
19
+ InfillResult,
20
+ GapFill,
21
+ CompareInfillRequest,
22
+ CompareInfillResponse,
23
+ ModelInfillResult,
24
+ )
25
+ from app.logic.infill_utils import (
26
+ detect_gaps,
27
+ parse_infill_json,
28
+ apply_fills,
29
+ build_fills_dict,
30
+ normalize_gaps_to_tagged,
31
  )
32
  from app.auth.placeholder_auth import get_authenticated_user
33
 
 
259
  "user_id": user['user_id'],
260
  "email": user['email'],
261
  "name": user.get('name', 'Unknown')
262
+ }
263
+
264
+
265
+ # --- Batch Infill Endpoints ---
266
+
267
+ @app.post("/infill", response_model=InfillResponse)
268
+ async def batch_infill(
269
+ request: InfillRequest,
270
+ user: Optional[dict] = Depends(get_authenticated_user)
271
+ ):
272
+ """
273
+ Batch gap-filling for ads using a single model.
274
+
275
+ Accepts items with [GAP:n] markers or ___ and returns filled text
276
+ with per-gap choices and alternatives.
277
+
278
+ NOTE: For texts > 6000 chars, consider chunking (not yet implemented).
279
+ """
280
+ total_start = time.time()
281
+
282
+ # Validate model
283
+ if request.model not in registry.get_available_model_names():
284
+ raise HTTPException(status_code=400, detail=f"Unknown model: {request.model}")
285
+
286
+ # Load domain config for infill prompt
287
+ domain_config = get_domain_config(request.domain)
288
+ if "create_infill_prompt" not in domain_config:
289
+ raise HTTPException(
290
+ status_code=400,
291
+ detail=f"Domain '{request.domain}' does not support infill operations"
292
+ )
293
+ create_infill_prompt = domain_config["create_infill_prompt"]
294
+
295
+ # Process each item
296
+ results = []
297
+ error_count = 0
298
+
299
+ for item in request.items:
300
+ result = await process_infill_item(
301
+ item=item,
302
+ model_name=request.model,
303
+ options=request.options,
304
+ create_infill_prompt=create_infill_prompt
305
+ )
306
+ results.append(result)
307
+ if result.status == "error":
308
+ error_count += 1
309
+
310
+ return InfillResponse(
311
+ model=request.model,
312
+ results=results,
313
+ total_time=round(time.time() - total_start, 2),
314
+ processed_count=len(results),
315
+ error_count=error_count
316
+ )
317
+
318
+
319
+ @app.post("/compare-infill", response_model=CompareInfillResponse)
320
+ async def compare_infill(
321
+ request: CompareInfillRequest,
322
+ user: Optional[dict] = Depends(get_authenticated_user)
323
+ ):
324
+ """
325
+ Multi-model batch gap-filling comparison for A/B testing.
326
+
327
+ Runs the same batch of items through multiple models and returns
328
+ per-model results for comparison.
329
+ """
330
+ total_start = time.time()
331
+
332
+ # Get models to compare
333
+ available_models = registry.get_available_model_names()
334
+ models_to_use = request.models if request.models else available_models
335
+
336
+ # Validate requested models
337
+ for model in models_to_use:
338
+ if model not in available_models:
339
+ raise HTTPException(status_code=400, detail=f"Unknown model: {model}")
340
+
341
+ # Load domain config
342
+ domain_config = get_domain_config(request.domain)
343
+ if "create_infill_prompt" not in domain_config:
344
+ raise HTTPException(
345
+ status_code=400,
346
+ detail=f"Domain '{request.domain}' does not support infill operations"
347
+ )
348
+ create_infill_prompt = domain_config["create_infill_prompt"]
349
+
350
+ # Process with each model (sequentially for memory safety)
351
+ model_results = []
352
+
353
+ for model_name in models_to_use:
354
+ model_start = time.time()
355
+ results = []
356
+ error_count = 0
357
+
358
+ for item in request.items:
359
+ result = await process_infill_item(
360
+ item=item,
361
+ model_name=model_name,
362
+ options=request.options,
363
+ create_infill_prompt=create_infill_prompt
364
+ )
365
+ results.append(result)
366
+ if result.status == "error":
367
+ error_count += 1
368
+
369
+ model_results.append(ModelInfillResult(
370
+ model=model_name,
371
+ type=MODEL_CONFIG[model_name]["type"],
372
+ results=results,
373
+ time=round(time.time() - model_start, 2),
374
+ error_count=error_count
375
+ ))
376
+
377
+ return CompareInfillResponse(
378
+ domain=request.domain,
379
+ models=model_results,
380
+ total_time=round(time.time() - total_start, 2)
381
+ )
382
+
383
+
384
+ async def process_infill_item(
385
+ item,
386
+ model_name: str,
387
+ options,
388
+ create_infill_prompt
389
+ ) -> InfillResult:
390
+ """
391
+ Process a single infill item.
392
+
393
+ Returns InfillResult with status, filled_text, and gaps.
394
+ """
395
+ try:
396
+ # Normalize gaps to [GAP:n] format
397
+ normalized_text, gaps = normalize_gaps_to_tagged(item.text_with_gaps)
398
+
399
+ if not gaps:
400
+ # No gaps found, return original text
401
+ return InfillResult(
402
+ id=item.id,
403
+ status="ok",
404
+ filled_text=item.text_with_gaps,
405
+ gaps=[],
406
+ error=None
407
+ )
408
+
409
+ # Build prompt
410
+ chat_messages = create_infill_prompt(normalized_text, options)
411
+
412
+ # Generate
413
+ llm = await registry.get_model(model_name)
414
+ raw_output = await llm.generate(
415
+ chat_messages=chat_messages,
416
+ max_new_tokens=options.max_new_tokens,
417
+ temperature=options.temperature,
418
+ top_p=0.9,
419
+ )
420
+
421
+ # Parse JSON from output
422
+ parsed = parse_infill_json(raw_output)
423
+
424
+ if not parsed:
425
+ # JSON parsing failed
426
+ return InfillResult(
427
+ id=item.id,
428
+ status="error",
429
+ filled_text=None,
430
+ gaps=[],
431
+ error=f"Failed to parse JSON from model output: {raw_output[:200]}..."
432
+ )
433
+
434
+ # Extract gaps and build result
435
+ gap_fills = []
436
+ fills_dict = {}
437
+
438
+ for gap_data in parsed.get("gaps", []):
439
+ gap_fill = GapFill(
440
+ index=gap_data.get("index", 0),
441
+ marker=gap_data.get("marker", ""),
442
+ choice=gap_data.get("choice", ""),
443
+ alternatives=gap_data.get("alternatives", [])
444
+ )
445
+ gap_fills.append(gap_fill)
446
+ fills_dict[gap_fill.index] = gap_fill.choice
447
+
448
+ # Get filled text - prefer model's version, fallback to reconstruction
449
+ filled_text = parsed.get("filled_text")
450
+ if not filled_text and fills_dict:
451
+ filled_text = apply_fills(normalized_text, gaps, fills_dict)
452
+
453
+ return InfillResult(
454
+ id=item.id,
455
+ status="ok",
456
+ filled_text=filled_text,
457
+ gaps=gap_fills,
458
+ error=None
459
+ )
460
+
461
+ except Exception as e:
462
+ return InfillResult(
463
+ id=item.id,
464
+ status="error",
465
+ filled_text=None,
466
+ gaps=[],
467
+ error=str(e)
468
+ )
app/schemas/schemas.py CHANGED
@@ -1,4 +1,4 @@
1
- from pydantic import BaseModel
2
  from typing import List, Optional, Dict, Any
3
 
4
 
@@ -9,6 +9,95 @@ class EnhancedDescriptionResponse(BaseModel):
9
  user_email: str
10
 
11
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  class ModelInfo(BaseModel):
13
  name: str
14
  model_id: str
 
1
+ from pydantic import BaseModel, Field
2
  from typing import List, Optional, Dict, Any
3
 
4
 
 
9
  user_email: str
10
 
11
 
12
+ # --- Batch Infill Schemas ---
13
+
14
+ class InfillItem(BaseModel):
15
+ """A single item (ad) with gaps to be filled."""
16
+ id: str = Field(..., description="Unique identifier for this item")
17
+ text_with_gaps: str = Field(..., description="Text containing [GAP:n] markers or ___ to fill")
18
+
19
+
20
+ class InfillOptions(BaseModel):
21
+ """Configuration options for infill processing."""
22
+ gap_notation: str = Field(
23
+ default="auto",
24
+ description="Gap notation: 'auto' (detect), '[GAP:n]', or '___'"
25
+ )
26
+ top_n_per_gap: int = Field(
27
+ default=3,
28
+ ge=1,
29
+ le=5,
30
+ description="Number of alternative suggestions per gap (1-5)"
31
+ )
32
+ language: str = Field(default="pl", description="Output language (pl/en)")
33
+ temperature: float = Field(default=0.6, ge=0.0, le=1.0)
34
+ max_new_tokens: int = Field(default=256, ge=50, le=512)
35
+
36
+
37
+ class GapFill(BaseModel):
38
+ """Result for a single filled gap."""
39
+ index: int = Field(..., description="Gap index (1-based)")
40
+ marker: str = Field(..., description="Original marker (e.g., '[GAP:1]' or '___')")
41
+ choice: str = Field(..., description="Selected fill word/phrase")
42
+ alternatives: List[str] = Field(
43
+ default_factory=list,
44
+ description="Alternative suggestions"
45
+ )
46
+
47
+
48
+ class InfillResult(BaseModel):
49
+ """Result for a single infill item."""
50
+ id: str
51
+ status: str = Field(..., description="'ok' or 'error'")
52
+ filled_text: Optional[str] = Field(None, description="Text with gaps filled")
53
+ gaps: List[GapFill] = Field(default_factory=list)
54
+ error: Optional[str] = Field(None, description="Error message if status='error'")
55
+
56
+
57
+ class InfillRequest(BaseModel):
58
+ """Request for single-model batch infill."""
59
+ domain: str = Field(..., description="Domain name (e.g., 'cars')")
60
+ items: List[InfillItem] = Field(..., description="Batch of items to process")
61
+ model: str = Field(default="bielik-1.5b", description="Model to use")
62
+ options: InfillOptions = Field(default_factory=InfillOptions)
63
+
64
+
65
+ class InfillResponse(BaseModel):
66
+ """Response for single-model batch infill."""
67
+ model: str
68
+ results: List[InfillResult]
69
+ total_time: float
70
+ processed_count: int
71
+ error_count: int
72
+
73
+
74
+ class CompareInfillRequest(BaseModel):
75
+ """Request for multi-model batch infill comparison."""
76
+ domain: str
77
+ items: List[InfillItem]
78
+ models: Optional[List[str]] = Field(
79
+ None,
80
+ description="Models to compare. If None, use all available."
81
+ )
82
+ options: InfillOptions = Field(default_factory=InfillOptions)
83
+
84
+
85
+ class ModelInfillResult(BaseModel):
86
+ """Per-model results in comparison."""
87
+ model: str
88
+ type: str
89
+ results: List[InfillResult]
90
+ time: float
91
+ error_count: int
92
+
93
+
94
+ class CompareInfillResponse(BaseModel):
95
+ """Response for multi-model batch infill comparison."""
96
+ domain: str
97
+ models: List[ModelInfillResult]
98
+ total_time: float
99
+
100
+
101
  class ModelInfo(BaseModel):
102
  name: str
103
  model_id: str