File size: 10,636 Bytes
62065d2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
"""
Docs-to-context-pack mapping and relevance rules for richer AI form generation.
Consumes LLM documentation (from docs API) and builds token-budgeted prompt sections
based on detected intent (conditional logic, document extraction, advanced fields).
"""

from typing import Any, Dict, List, Optional, Tuple
import json
import logging

logger = logging.getLogger(__name__)

# Approximate tokens from chars (conservative for Latin text)
CHARS_PER_TOKEN = 4

# --- Intent detection keywords (plan: Smart Context Strategy) ---
CONDITIONAL_LOGIC_SIGNALS = [
    "if", "when", "depends", "show", "hide", "branch", "conditional",
    "only when", "based on", "depending on", "skip", "reveal",
]
DOC_EXTRACTION_SIGNALS = [
    "resume", "cv", "invoice", "receipt", "id card", "parse", "extract",
    "document extraction", "upload and extract", "pdf", "scan",
]
ADVANCED_FIELD_SIGNALS = [
    "matrix", "ranking", "nps", "likert", "rating", "score", "slider",
    "validation", "validate", "scale", "grid",
]

# --- Pack names and doc section mapping ---
PACK_FIELD_TYPES = "fieldTypes"
PACK_CONDITIONAL_LOGIC = "conditionalLogic"
PACK_DOC_EXTRACTION = "aiFields"
PACK_EXAMPLES = "exampleForms"
PACK_BEST_PRACTICES = "bestPractices"
PACK_FORM_SCHEMA = "formSchema"

# Default character budgets (approx 4 chars per token; ~500 tokens per pack)
MAX_CHARS_PER_PACK = 2000
MAX_TOTAL_CONTEXT_CHARS = 8000


def detect_intent(
    description: str = "",
    user_request: Optional[str] = None,
    current_fields: Optional[List[Dict[str, Any]]] = None,
    conversation_context: Optional[List[str]] = None,
) -> Dict[str, bool]:
    """
    Detect which context packs are relevant from user text and current form.
    Returns flags: needs_conditional_logic, needs_document_extraction, needs_advanced_fields.
    """
    text = " ".join(
        filter(
            None,
            [description or "", user_request or ""]
            + (conversation_context or []),
        )
    ).lower()
    has_conditional = any(s in text for s in CONDITIONAL_LOGIC_SIGNALS)
    has_doc_extraction = any(s in text for s in DOC_EXTRACTION_SIGNALS)
    has_advanced = any(s in text for s in ADVANCED_FIELD_SIGNALS)
    # If current form already has conditional logic or document-extraction, include those packs for refine
    if current_fields:
        for f in current_fields:
            if isinstance(f, dict):
                if (f.get("conditionalLogic") or {}).get("enabled"):
                    has_conditional = True
                if f.get("type") == "document-extraction":
                    has_doc_extraction = True
    return {
        "needs_conditional_logic": has_conditional,
        "needs_document_extraction": has_doc_extraction,
        "needs_advanced_fields": has_advanced,
    }


def _truncate(text: str, max_chars: int) -> str:
    if len(text) <= max_chars:
        return text
    return text[: max_chars - 3].rstrip() + "..."


def _pack_field_types(docs: Dict[str, Any], max_chars: int) -> str:
    """Build FieldTypes pack from docs.fieldTypes (summary + key types)."""
    field_types = docs.get("fieldTypes") or {}
    if not field_types:
        return ""
    lines = ["## Field types reference\n"]
    for name, spec in list(field_types.items())[:25]:
        if not isinstance(spec, dict):
            continue
        desc = spec.get("description", "")
        required = spec.get("requiredProperties", [])
        lines.append(f"- **{name}**: {desc[:200]}")
        if required:
            lines.append(f"  Required: {', '.join(required)}")
        ex = spec.get("exampleJSON")
        if ex:
            lines.append(f"  Example: {json.dumps(ex)[:300]}")
    out = "\n".join(lines)
    return _truncate(out, max_chars)


def _pack_conditional_logic(docs: Dict[str, Any], max_chars: int) -> str:
    """Build ConditionalLogic pack from docs.conditionalLogic."""
    cl = docs.get("conditionalLogic") or {}
    if not cl:
        return ""
    lines = [
        "## Conditional logic\n",
        (cl.get("overview") or "")[:500],
        "\n### Operators (use in conditions): ",
    ]
    ops = cl.get("operators") or {}
    for op_name, op_spec in list(ops.items())[:15]:
        if isinstance(op_spec, dict):
            lines.append(f"- {op_name}: {(op_spec.get('description') or '')[:150]}")
    lines.append("\n### Actions: show | hide | validate | cap_responses")
    actions = cl.get("actions") or {}
    for act_name, act_spec in list(actions.items())[:4]:
        if isinstance(act_spec, dict):
            ex = act_spec.get("example")
            if ex:
                lines.append(f"- {act_name}: {json.dumps(ex)[:200]}")
    examples = cl.get("examples") or []
    for ex in examples[:2]:
        if isinstance(ex, dict) and ex.get("json"):
            lines.append(f"Example: {json.dumps(ex['json'])[:250]}")
    out = "\n".join(lines)
    return _truncate(out, max_chars)


def _pack_doc_extraction(docs: Dict[str, Any], max_chars: int) -> str:
    """Build DocExtraction pack from docs.aiFields.documentExtraction."""
    ai = docs.get("aiFields") or {}
    doc_ext = ai.get("documentExtraction") if isinstance(ai, dict) else None
    if not doc_ext:
        return ""
    lines = [
        "## Document extraction field (document-extraction)\n",
        (doc_ext.get("description") or "")[:400],
        "\n### customFields: array of { id, name, description, fieldType, required, editable }",
        "fieldType: single_value | list | number | date",
        "Always set acceptedFileTypes (e.g. ['pdf','doc','docx','png','jpg','jpeg']) and maxFileSize in bytes (e.g. 10485760 for 10MB). Do not leave customFields empty.",
        "verificationPrompt: descriptive text only (full sentences) stating how to verify the candidate against extracted data. Use the user's must-have/role requirements (e.g. from clarifying answers: years of experience, degree, skills). Never put only a number (e.g. 80) — invalid; use criteria like 'Candidate must have 2+ years experience, masters in CS, and LangGraph expertise.'",
    ]
    config = doc_ext.get("configuration") or {}
    if isinstance(config, dict):
        cf = config.get("customFields") or {}
        if isinstance(cf, dict) and cf.get("fieldTypes"):
            lines.append("Field types: " + json.dumps(list((cf["fieldTypes"] or {}).keys())))
    example_configs = doc_ext.get("exampleConfigurations") or []
    for ex in example_configs[:2]:
        if isinstance(ex, dict) and ex.get("json"):
            lines.append(f"Example: {json.dumps(ex['json'])[:400]}")
    out = "\n".join(lines)
    return _truncate(out, max_chars)


def _pack_examples(docs: Dict[str, Any], max_chars: int, intent: Dict[str, bool]) -> str:
    """Build Examples pack: 1–3 examples matching intent."""
    examples = docs.get("exampleForms") or []
    if not examples:
        return ""
    chosen = []
    for ex in examples:
        if not isinstance(ex, dict) or not ex.get("json"):
            continue
        title = (ex.get("title") or "").lower()
        features = ex.get("featuresUsed") or []
        if intent.get("needs_document_extraction") and (
            "document-extraction" in features or "resume" in title or "extraction" in title
        ):
            chosen.append(ex)
        elif intent.get("needs_conditional_logic") and "conditionalLogic" in str(features):
            chosen.append(ex)
        elif not chosen:
            chosen.append(ex)
        if len(chosen) >= 3:
            break
    if not chosen:
        chosen = examples[:2]
    lines = ["## Example forms (reference only)\n"]
    for ex in chosen:
        lines.append(f"### {ex.get('title', 'Form')}")
        lines.append(json.dumps(ex.get("json") or {}, indent=2)[:800])
    out = "\n".join(lines)
    return _truncate(out, max_chars)


def build_context_packs(
    docs: Dict[str, Any],
    intent: Dict[str, bool],
    max_chars_per_pack: int = MAX_CHARS_PER_PACK,
    max_total_chars: int = MAX_TOTAL_CONTEXT_CHARS,
    log_usage: bool = True,
) -> str:
    """
    Build a single formatted context string from docs and intent.
    Packs are included by relevance; total output is capped by max_total_chars.
    When log_usage is True, logs pack names and approximate token count.
    """
    sections = []
    used = 0
    pack_names: List[str] = []

    # 1. Form schema (compact, always useful)
    form_schema = docs.get("formSchema") or {}
    if form_schema:
        schema_str = json.dumps(form_schema.get("example") or form_schema)[:800]
        block = f"## Form structure\n{schema_str}\n"
        if used + len(block) <= max_total_chars:
            sections.append(block)
            used += len(block)
            pack_names.append("formSchema")

    # 2. Field types (always include, truncated)
    block = _pack_field_types(docs, max_chars_per_pack)
    if block and used + len(block) <= max_total_chars:
        sections.append(block)
        used += len(block)
        pack_names.append("fieldTypes")

    # 3. Conditional logic (if intent says so)
    if intent.get("needs_conditional_logic"):
        block = _pack_conditional_logic(docs, max_chars_per_pack)
        if block and used + len(block) <= max_total_chars:
            sections.append(block)
            used += len(block)
            pack_names.append("conditionalLogic")

    # 4. Document extraction (if intent says so)
    if intent.get("needs_document_extraction"):
        block = _pack_doc_extraction(docs, max_chars_per_pack)
        if block and used + len(block) <= max_total_chars:
            sections.append(block)
            used += len(block)
            pack_names.append("docExtraction")

    # 5. Examples (1–3 matching intent)
    block = _pack_examples(docs, max_chars_per_pack, intent)
    if block and used + len(block) <= max_total_chars:
        sections.append(block)
        used += len(block)
        pack_names.append("examples")

    # 6. Best practices (short)
    practices = docs.get("bestPractices") or []
    if practices and used < max_total_chars:
        block = "## Best practices\n" + "\n".join(f"- {p}" for p in practices[:8])
        block = _truncate(block, max_total_chars - used)
        if block:
            sections.append(block)
            used += len(block)
            pack_names.append("bestPractices")

    out = "\n\n".join(sections) if sections else ""
    if log_usage and pack_names:
        approx_tokens = len(out) // CHARS_PER_TOKEN
        logger.info(
            "context_packs built packs=%s total_chars=%s approx_tokens=%s",
            pack_names,
            len(out),
            approx_tokens,
        )
    return out