File size: 15,132 Bytes
77da5ce
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
"""
intake.py β€” LifeStack Conversational Onboarding
Extracts a structured life state, conflict, and personality profile
from a user's natural language description + slider inputs.
"""

import os
import json
from openai import OpenAI
from core.life_state import LifeMetrics, ResourceBudget
from core.metric_schema import VALID_METRIC_PATHS, normalize_metric_path, is_valid_metric_path
from agent.conflict_generator import ConflictEvent, TEMPLATES


class LifeIntake:
    def __init__(self):
        self.api_key = os.getenv("GROQ_API_KEY")

        # Fallback to .env file
        if not self.api_key and os.path.exists(".env"):
            try:
                with open(".env") as f:
                    for line in f:
                        if line.startswith("GROQ_API_KEY="):
                            self.api_key = line.split("=", 1)[1].strip()
                            break
            except Exception:
                pass

        self.client = None
        if self.api_key:
            self.client = OpenAI(
                base_url="https://api.groq.com/openai/v1",
                api_key=self.api_key,
            )

        # HuggingFace Inference API β€” primary LLM path when HF_TOKEN is set
        self.hf_client = None
        hf_token = os.getenv("HF_TOKEN")
        if hf_token:
            try:
                from huggingface_hub import InferenceClient
                self.hf_client = InferenceClient(
                    model="Qwen/Qwen2.5-1.5B-Instruct",
                    token=hf_token,
                )
            except ImportError:
                pass

        self.model = "llama-3.1-8b-instant"
        self.conversation_history = []

    def _call_llm(self, prompt: str, max_tokens: int = 300) -> str:
        """Internal LLM call β€” cascades HF Inference API β†’ Groq β†’ empty-string fallback."""
        import time as _t
        import re

        def _strip_fences(text: str) -> str:
            if text.startswith("```json"):
                return text[7:].rsplit("```", 1)[0].strip()
            if text.startswith("```"):
                return text[3:].rsplit("```", 1)[0].strip()
            return text

        # ── 1. HuggingFace Inference API (primary) ──────────────────────────
        if self.hf_client:
            try:
                resp = self.hf_client.chat_completion(
                    messages=[{"role": "user", "content": prompt}],
                    max_tokens=max_tokens,
                )
                return _strip_fences(resp.choices[0].message.content.strip())
            except Exception as e:
                print(f"  ⚠️  HF Inference failed ({e}), falling back to Groq.")

        # ── 2. Groq fallback ─────────────────────────────────────────────────
        if not self.client:
            return ""

        for attempt in range(3):
            try:
                response = self.client.chat.completions.create(
                    model=self.model,
                    messages=[{"role": "user", "content": prompt}],
                    temperature=0.2,
                    max_tokens=max_tokens,
                )
                return _strip_fences(response.choices[0].message.content.strip())
            except Exception as e:
                err = str(e)
                if "429" in err and attempt < 2:
                    wait_secs = 5.0
                    m = re.search(r"try again in (\d+)m([\d.]+)s", err)
                    if m:
                        wait_secs = int(m.group(1)) * 60 + float(m.group(2))
                    else:
                        m = re.search(r"try again in ([\d.]+)s", err)
                        if m:
                            wait_secs = float(m.group(1))
                    if wait_secs > 5.0:
                        print(f"  ⚠️  Rate limit β€” skipping Groq call ({wait_secs:.0f}s wait)")
                        return ""
                    _t.sleep(wait_secs)
                else:
                    print(f"  ⚠️  Groq call failed: {e}")
                    return ""
        return ""

    def _match_template_by_keywords(self, text: str):
        """Keyword-overlap fallback: find the best-matching built-in template."""
        user_words = set(text.lower().split())
        best, best_score = None, 0
        for tpl in TEMPLATES:
            kw = set((tpl.title + " " + tpl.story).lower().split())
            score = len(kw & user_words)
            if score > best_score:
                best_score, best = score, tpl
        return best if best_score >= 2 else None

    # ─── 1. Slider β†’ LifeMetrics ──────────────────────────────────────────────
    def extract_life_state(
        self,
        user_description: str,
        work_stress: int,
        money_stress: int,
        relationship_quality: int,
        energy_level: int,
        time_pressure: int,
    ) -> LifeMetrics:
        """
        Maps slider values (0-10) directly to life metrics and returns
        a fully populated LifeMetrics object.
        """
        def clamp(v: float) -> float:
            return max(0.0, min(100.0, v))

        metrics = LifeMetrics()

        # Career
        metrics.career.workload               = clamp(50 + work_stress * 5)
        # (other career fields stay at 70)

        # Mental wellbeing
        metrics.mental_wellbeing.stress_level = clamp(40 + work_stress * 6)

        # Finances
        metrics.finances.liquidity            = clamp(100 - money_stress * 7)
        metrics.finances.debt_pressure        = clamp(40 + money_stress * 5)

        # Relationships
        metrics.relationships.romantic        = clamp(relationship_quality * 10)
        metrics.relationships.social          = clamp(40 + relationship_quality * 4)

        # Physical health
        metrics.physical_health.energy        = clamp(energy_level * 10)
        metrics.physical_health.sleep_quality = clamp(30 + energy_level * 7)

        # Time
        metrics.time.free_hours_per_week      = clamp(100 - time_pressure * 8)

        return metrics

    # ─── 2. NL description β†’ ConflictEvent ───────────────────────────────────
    def extract_conflict(self, user_description: str, metrics: LifeMetrics) -> ConflictEvent:
        """
        Sends the user description + key metric snapshot to the LLM
        and parses the response into a structured ConflictEvent.
        """
        flat = metrics.flatten()
        stress     = flat.get("mental_wellbeing.stress_level", 70)
        liquidity  = flat.get("finances.liquidity", 70)
        energy     = flat.get("physical_health.energy", 70)
        free_hours = flat.get("time.free_hours_per_week", 70)

        valid_paths = ", ".join(VALID_METRIC_PATHS)
        prompt = (
            f"The user described their situation as: {user_description}\n"
            f"Their life metrics show: stress={stress:.1f}, liquidity={liquidity:.1f}, "
            f"energy={energy:.1f}, free_hours={free_hours:.1f}.\n"
            "Extract a structured conflict. Respond ONLY with valid JSON (no markdown fences).\n"
            f"Use ONLY these exact metric path keys for primary_disruption: {valid_paths}\n"
            '{"title": "2-4 word title", "story": "one sentence description of the crisis", '
            '"primary_disruption": {"exact.metric_path": delta_as_float}, '
            '"decisions_required": ["option1", "option2", "option3"], '
            '"difficulty": integer_from_1_to_5}'
        )

        raw = self._call_llm(prompt, max_tokens=400)

        try:
            data = json.loads(raw)
            disruption = {}
            for k, v in data.get("primary_disruption", {}).items():
                norm_key = normalize_metric_path(k)
                if not is_valid_metric_path(norm_key):
                    continue
                try:
                    disruption[norm_key] = float(v)
                except (ValueError, TypeError):
                    pass

            return ConflictEvent(
                id="custom_intake",
                title=str(data.get("title", "Your Situation")),
                story=str(data.get("story", user_description)),
                primary_disruption=disruption or {"mental_wellbeing.stress_level": 20.0},
                decisions_required=list(data.get("decisions_required", ["Take action", "Seek help", "Rest"])),
                resource_budget={"time": 10.0, "money": 200.0, "energy": 50.0},
                difficulty=int(data.get("difficulty", 3)),
            )
        except Exception as e:
            print(f"  ⚠️  Conflict parsing failed ({e}). Trying keyword match.")
            kw = self._match_template_by_keywords(user_description)
            if kw:
                print(f"  βœ…  Keyword match: {kw.title}")
                return kw
            return ConflictEvent(
                id="custom_intake",
                title="Your Situation",
                story=user_description or "Feeling overwhelmed and unsure what to do.",
                primary_disruption={"mental_wellbeing.stress_level": 20.0},
                decisions_required=["Take action", "Seek help", "Rest"],
                resource_budget={"time": 10.0, "money": 200.0, "energy": 50.0},
                difficulty=3,
            )

    # ─── 3. NL description β†’ OCEAN personality dict ───────────────────────────
    def get_personality_from_description(self, user_description: str) -> dict:
        """
        Infers OCEAN personality trait scores from the user's natural
        language description. Returns a dict or balanced defaults on failure.
        """
        prompt = (
            f"Based on this description of someone's situation:\n{user_description}\n\n"
            "Infer their likely OCEAN personality traits as float values between 0.0 and 1.0. "
            "Also infer a likely first name that fits the personality. "
            "Respond ONLY with valid JSON, no extra text:\n"
            '{"openness": 0.65, "conscientiousness": 0.75, '
            '"extraversion": 0.30, "agreeableness": 0.55, '
            '"neuroticism": 0.80, "name": "Sam"}'
        )

        raw = self._call_llm(prompt, max_tokens=200)

        defaults = {
            "openness": 0.5,
            "conscientiousness": 0.5,
            "extraversion": 0.5,
            "agreeableness": 0.5,
            "neuroticism": 0.5,
            "name": "You",
        }

        try:
            data = json.loads(raw)
            result = {}
            for trait in ["openness", "conscientiousness", "extraversion", "agreeableness", "neuroticism"]:
                try:
                    result[trait] = float(data[trait])
                except (KeyError, ValueError, TypeError):
                    result[trait] = defaults[trait]
            result["name"] = str(data.get("name", "You"))
            return result
        except Exception as e:
            print(f"  ⚠️  Personality parsing failed ({e}). Using balanced defaults.")
            return defaults

    # ─── 4. Full intake β€” single entry point for app.py Tab 2 ─────────────────
    def full_intake(
        self,
        user_description: str,
        work_stress: int,
        money_stress: int,
        relationship_quality: int,
        energy_level: int,
        time_pressure: int,
        calendar_signals: dict = None,
        gmail_signals: dict = None,
    ) -> tuple:
        """
        Runs all three extraction steps and returns:
            (LifeMetrics, ResourceBudget, ConflictEvent, personality_dict)
        """
        metrics = self.extract_life_state(
            user_description, work_stress, money_stress,
            relationship_quality, energy_level, time_pressure
        )

        # Apply Gmail/Calendar signal adjustments if provided
        signals = {}
        if calendar_signals: signals.update(calendar_signals)
        if gmail_signals: signals.update(gmail_signals)

        for path, val in signals.items():
            if '.' not in path: continue
            domain_name, sub_name = path.split('.')
            domain = getattr(metrics, domain_name, None)
            if domain and hasattr(domain, sub_name):
                # Signals like social/romantic/network from Gmail are treated as base values (overrides)
                # while others like stress/free_time are cumulative deltas.
                if any(x in sub_name for x in ["social", "romantic", "network", "professional"]):
                    setattr(domain, sub_name, max(0.0, min(100.0, val)))
                else:
                    current = getattr(domain, sub_name)
                    setattr(domain, sub_name, max(0.0, min(100.0, current + val)))

        conflict    = self.extract_conflict(user_description, metrics)
        personality = self.get_personality_from_description(user_description)
        budget      = ResourceBudget()

        return metrics, budget, conflict, personality


# ─── Main test ────────────────────────────────────────────────────────────────
def main():
    description = (
        "My boss keeps piling on work and I haven't slept properly in weeks. "
        "My partner says I am distant and I don't have the energy to fix it."
    )
    work_stress         = 8
    money_stress        = 4
    relationship_quality = 5
    energy_level        = 3
    time_pressure       = 7

    print("πŸš€ Running LifeIntake...\n")
    intake = LifeIntake()
    metrics, budget, conflict, personality = intake.full_intake(
        description, work_stress, money_stress,
        relationship_quality, energy_level, time_pressure
    )

    print("-" * 50)
    print("πŸ“Š EXTRACTED LIFE METRICS")
    print("-" * 50)
    flat = metrics.flatten()
    for key, val in flat.items():
        icon = "🟒" if val > 70 else ("🟑" if val >= 40 else "πŸ”΄")
        print(f"  {icon} {key:40}: {val:.1f}")

    print("\n─" * 50)
    print("⚑ EXTRACTED CONFLICT")
    print("-" * 50)
    print(f"  Title      : {conflict.title}")
    print(f"  Difficulty : {conflict.difficulty}/5")
    print(f"  Story      : {conflict.story}")
    print(f"  Disruption : {conflict.primary_disruption}")
    print(f"  Options    : {conflict.decisions_required}")

    print("\n─" * 50)
    print("🧠 INFERRED PERSONALITY")
    print("-" * 50)
    for trait, val in personality.items():
        if trait != "name":
            print(f"  {trait:20}: {val:.2f}")
    print(f"  {'name':20}: {personality['name']}")

    print(f"\nβœ… Budget β€” Time: {budget.time_hours}h | Money: ${budget.money_dollars} | Energy: {budget.energy_units}")


if __name__ == "__main__":
    main()