diff --git a/CLAUDE.md b/CLAUDE.md index 2217f43..878b60d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,6 +55,33 @@ Required in `.env`: - `_pick_response_budget()` in main.py controls caller dialog token limits (150-450 tokens). MiniMax respects limits strictly — if responses seem short, check these values. - Default max_tokens in llm.py is 300 (for non-caller uses) - Grok (`x-ai/grok-4-fast`) works well for natural dialog; MiniMax tends toward terse responses +- `generate_with_tools()` in llm.py supports OpenRouter function calling for the intern feature + +## Caller Generation System +- **CallerBackground dataclass**: Structured output from LLM background generation (JSON mode). Fields: name, age, gender, job, location, reason_for_calling, pool_name, communication_style, energy_level, emotional_state, signature_detail, situation_summary, natural_description, seeds, verbal_fluency, calling_from. +- **Voice-personality matching**: `_match_voices_to_styles()` runs after background generation. 68 voice profiles in `VOICE_PROFILES` (tts.py), 18 style-to-voice mappings in `STYLE_VOICE_PREFERENCES` (main.py). Soft matching — scores voices against style preferences. +- **Adaptive call shapes**: `SHAPE_STYLE_AFFINITIES` maps communication styles to shape weight multipliers. Consecutive shape repeats are dampened. +- **Inter-caller awareness**: Thematic matching in `get_show_history()` scores previous callers by keyword/category overlap. Adaptive reaction frequency (60%/35%/15%). Show energy tracking via `_get_show_energy()`. +- **Caller memory**: Returning callers store structured backgrounds, key moments, arc status, and relationships with other regulars. `RegularCallerService` has `add_relationship()` and expanded `update_after_call()`. +- **Show pacing**: `_sort_caller_queue()` sorts presentation order by energy alternation, topic variety, shape variety. +- **Call quality signals**: `_assess_call_quality()` captures exchange count, response length, host engagement, shape target hit, natural ending. + +## Devon (Intern Character) +- **Service**: `backend/services/intern.py` — persistent show character, not a caller +- **Personality**: 23-year-old NMSU grad, eager, slightly incompetent, gets yelled at. Voice: "Nate" (Inworld), no phone filter. +- **Tools**: web_search (SearXNG), get_headlines, fetch_webpage, wikipedia_lookup — via `generate_with_tools()` function calling +- **Endpoints**: `POST /api/intern/ask`, `/interject`, `/monitor`, `GET /api/intern/suggestion`, `POST /api/intern/suggestion/play`, `/dismiss` +- **Auto-monitoring**: Watches conversation every 15s during calls, buffers suggestions for host approval +- **Persistence**: `data/intern.json` stores lookup history +- **Frontend**: Ask Devon input (D key), Interject button, monitor toggle, suggestion indicator with Play/Dismiss + +## Frontend Control Panel +- **Keyboard shortcuts**: 1-0 (callers), H (hangup), W (wrap up), M (music toggle), D (ask Devon), Escape (close modals) +- **Wrap It Up**: Amber button that signals callers to wind down gracefully. Reduces response budget, injects wrap-up signals, forces goodbye after 2 exchanges. +- **Caller info panel**: Shows call shape, energy level, emotional state, signature detail, situation summary during active calls +- **Caller buttons**: Energy dots (colored by level) and shape badges on each button +- **Pinned SFX**: Cheer/Applause/Boo always visible, rest collapsible +- **Visual polish**: Thinking pulse, call glow, compact media row, smoother transitions ## Website - **Domain**: lukeattheroost.com (behind Cloudflare) diff --git a/backend/main.py b/backend/main.py index e300db2..3688e18 100644 --- a/backend/main.py +++ b/backend/main.py @@ -7,7 +7,7 @@ import base64 import subprocess import threading import traceback -from dataclasses import dataclass, field +from dataclasses import dataclass, field, asdict from pathlib import Path from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect, Request, Response from fastapi.staticfiles import StaticFiles @@ -29,6 +29,7 @@ from .services.audio import audio_service from .services.stem_recorder import StemRecorder from .services.news import news_service, extract_keywords, STOP_WORDS from .services.regulars import regular_caller_service +from .services.intern import intern_service app = FastAPI(title="AI Radio Show") @@ -1019,6 +1020,17 @@ PROBLEMS = [ "got a call from their kid's principal saying the kid brought something to school they shouldn't have — the meeting is tomorrow and they have no idea what it is", "their HOA is forcing them to remove a wheelchair ramp they built for their disabled spouse because it 'doesn't match the aesthetic'", "found out their retirement date just got pushed back five years because of a pension rule change nobody told them about", + # Self-inflicted / ego-driven problems + "has been pretending to know how to swim for their entire adult life and their spouse just booked a Caribbean cruise with a snorkeling excursion for their anniversary — they leave in three weeks and googled 'how to swim' last night and immediately closed the laptop when their wife walked in", + "has been telling their coworkers they speak fluent Italian for two years as a personality thing and now the company is sending them to Rome to lead a client meeting — they've been doing Duolingo fourteen hours a day and can currently order coffee and ask where the bathroom is", + "got into a road rage incident where they followed the other driver to a parking lot to yell at them — turns out the parking lot was a police station and the other driver was an off-duty officer who walked inside, came back out in uniform, and wrote them three tickets", + "told their new girlfriend they own their house and now she wants to move in — they rent, the landlord lives next door, and the girlfriend just introduced herself to the landlord and said 'so nice to meet our neighbor' and the landlord looked at the caller and raised one eyebrow", + "refused to apologize to their neighbor over a fence dispute out of principle and it's been four years — their kid and the neighbor's kid are now dating and both families have to sit at the same table for graduation dinner and nobody has acknowledged the fence once", + "has been faking an injury at work for three months to keep getting light duty and just found out the company hired a private investigator — they saw the PI's car outside their house while they were carrying two bags of concrete mix into the backyard for a patio project", + "got drunk and told their wife's entire family what they actually think of them at Thanksgiving dinner — called the brother-in-law a 'grown man who collects swords' and told the mother-in-law her casserole 'tastes like revenge' — and now their wife is saying she agrees with her family that he needs anger management but won't disagree about the casserole", + "made a fake LinkedIn profile to catfish their ex and accidentally built a real professional network with it — the fake persona now has 3,000 connections and a recruiter just reached out with a six-figure offer for a person who doesn't exist and the caller is seriously considering showing up to the interview in a wig", + "lied on their dating profile about being 6'1\" and they're 5'8\" — it worked until the woman showed up in heels and he was eye level with her chin, and when she said 'you're not six one' he said 'I am in boots' and she said 'you're wearing sneakers' and he said 'yeah'", + "told everyone at work they ran a marathon and now there's a company team signing up for one in their name — they have never run more than the length of a driveway and the race is in six weeks and their boss already ordered shirts with their name on them", ] STORIES = [ @@ -1260,6 +1272,32 @@ STORIES = [ "accidentally mailed their rent check to their cable company and their cable payment to their landlord — neither noticed for two months", "keeps getting someone else's prescription glasses in the mail from an online eyewear company — the prescription is almost exactly theirs and the frames are nice so they've been wearing them", "found a journal wedged behind a bathroom wall during a renovation — it's someone's detailed diary from 1994 and the last entry says 'if you're reading this, the closet floor isn't what it seems'", + # Comedy writer entries + "walked in on their roommate having a full conversation with a sex doll at the kitchen table — not a sexual situation, they were eating breakfast across from it and arguing about politics — and when the caller said 'what the hell' the roommate said 'do you mind, we're in the middle of something'", + "got a lap dance at a strip club and halfway through realized the dancer was their kid's second-grade teacher — they made eye contact, she said 'we will never speak of this,' he said 'agreed,' and now they both pretend not to recognize each other at parent-teacher conferences", + "accidentally liked their ex's Instagram photo from 2019 at 3am and instead of unliking it they panicked and liked every single photo going back to 2016 so it would look like they were hacked — the ex called the next morning and said 'are you okay' and they said 'I think someone got into my account' and the ex said 'whoever it was also ordered you a pizza because I can see the Domino's box on your story'", + "clogged the toilet at their boss's dinner party and couldn't find a plunger so they reached in barehanded and fixed it — washed their hands for five minutes, came back to the table, and their boss handed them a bread roll and said 'you've got great hands, you should try the piano' and they've never told anyone until now", + "their elderly neighbor died and they went to the estate sale and accidentally bought back their own lawnmower the neighbor had 'borrowed' seven years ago — they paid forty dollars for their own property and didn't realize until they saw the scratch mark from when their kid hit the fence with it", + "went on a first date and the woman asked 'what do you do for fun' and they blanked so hard they said 'I collect rocks' — they don't collect rocks, have never collected rocks, but now they're six dates in and she bought them a geode for their birthday and they have a shelf of rocks they pretend to care about", + "farted so loud during a moment of silence at a funeral that the pastor stopped and looked directly at them — the deceased's wife started laughing which made the whole front row laugh and now the family says grandpa would have loved it but the caller has not recovered", + "got pulled over doing 95 in a 55 and when the cop asked where the fire was they accidentally said 'my wife is having a baby' — the cop gave them a full escort to the hospital with lights and sirens and they had to stand in the maternity ward explaining to nurses that nobody was actually pregnant while the cop waited to congratulate them", + "accidentally sent a sext meant for their girlfriend to the family group chat — their dad responded 'wrong chat, son' and their mother hasn't spoken to them in three weeks but their uncle sent a thumbs up", + "their coworker microwaved fish at work and when someone complained, the coworker brought in a laminated printout of the company handbook highlighting that there's no policy against it — then started microwaving increasingly aggressive fish every day as a form of protest and HR is now involved in what they're calling 'the fish situation'", + "told their barber they liked the haircut when they didn't and has been going to the same barber getting the same bad haircut for four years because they can't figure out how to ask for something different without admitting they've been lying since the first visit", + "went to their high school reunion and someone said 'you look exactly the same' and they can't figure out if it was a compliment because they were ugly in high school and they've been thinking about it every day for two months", + "sneezed during a work video call and their camera unfroze at the exact moment their face was fully contorted — someone screenshotted it and it's been the team's Slack emoji for six months and they can't get IT to remove it", + "their Tinder date showed up and it was their cousin's ex-wife — they both knew immediately but neither said anything and they sat through an entire dinner making small talk about the weather before she said 'this never happened' and he said 'what never happened' and they've never spoken again", + "was at a urinal and their boss walked up to the one next to them and started a performance review — full eye contact, talked about quarterly goals, mentioned areas for improvement — and the caller didn't know whether to respond professionally or acknowledge that they were both holding their dicks", + "went to a couples massage with their wife and accidentally moaned — not a little, a full audible moan — and the masseuse stopped, their wife sat up, and nobody has spoken about it but his wife has not booked another massage and it's been eight months", + "got into a fender bender in a grocery store parking lot and when they got out to exchange information it was the same person they'd gotten into a fender bender with two years ago in a different parking lot — the other driver said 'you again?' and they now have each other's insurance memorized", + "left a brutally honest Yelp review for a restaurant and the owner responded publicly with the caller's full order history — including seventeen orders of the dish they said they hated, a note that they always request extra ranch, and a reminder that they asked for a birthday discount three times in one year", + "called in sick to work to go to a baseball game and ended up on the Jumbotron — their manager was watching the broadcast and texted them 'nice seats, see you Monday' with no further comment and now they don't know if they're fired or forgiven", + "their smart speaker overheard them talking trash about their mother-in-law and added 'divorce lawyer' to their shopping list — their wife saw it before they did and the conversation that followed was worse than anything the lawyer could have helped with", + "was trying to impress a date by cooking dinner and set off the smoke alarm so badly that the fire department came — one of the firefighters looked at the pan and said 'were you trying to cook this or punish it' and the date married them anyway but tells this story at every party", + "accidentally wore their shirt inside out to a job interview, got the job, and has been wearing the same shirt inside out to work every day because they think it's lucky — a coworker finally told them after three months and they said 'I know' because admitting it was an accident felt worse", + "got into an argument with a stranger on the internet that lasted three days and when they finally looked at the profile picture they realized they'd been arguing with their own brother using a fake account — neither of them has brought it up in person", + "told a long, elaborate story at a dinner party and absolutely nailed the delivery — everyone laughed, people applauded — and then their spouse leaned over and whispered 'that happened to me, not you' and they've been telling this person's story as their own for so long they genuinely forgot", + "their dog got loose and when they found him he was sitting on the porch of a house three blocks away with another family who'd already named him, bought him a bowl, and seemed genuinely upset to give him back — the dog looked at the caller like he'd been caught cheating", ] ADVICE = [ @@ -1560,6 +1598,19 @@ ADVICE = [ "their house appraised for twice what they expected and now they're wondering if they should sell, downsize, and live off the difference — but it's the house their kids grew up in", "their identity was stolen and the thief racked up $30,000 in debt — the banks say it's not their problem and the police say it's the banks' problem and nobody is helping", "received a letter from a lawyer saying they're named in a will by someone they've never heard of — the inheritance is modest but the mystery is eating at them", + # Comedy writer entries + "has been lying about their salary to their spouse for six years — telling them they make $20k less than they do and putting the difference in a secret account — they've got $130k saved and the original reason was a surprise house down payment but now they're addicted to the secret and don't want to stop", + "found their teenage son's search history and it's not porn — it's hours of research on how to legally emancipate yourself from your parents — the kid is 15, gets good grades, and has never once complained, and the caller doesn't know if they should confront him or just sit with the fact that their kid is quietly planning to leave them", + "wants to know if they're a bad person for being relieved their mother-in-law's Alzheimer's is getting worse — she was cruel to them for twenty years, called them trash to their face at their own wedding, and now she smiles at them and holds their hand and they finally have the mother-in-law they always wanted and they feel sick about how good it feels", + "needs advice on whether to tell their friend that nobody likes their friend's cooking — the friend hosts dinner parties every month, everyone pretends the food is good, and now the friend is talking about quitting their accounting job to open a restaurant and the caller is the only one who can stop it but saying something means admitting they've been lying for three years", + "wants to know if it's okay to break up with someone because of how they chew — they've been together two years and everything else is perfect but the chewing is so loud they've started wearing earbuds at dinner and last week they had a dream about smothering their partner with a pillow and woke up feeling calm", + "let their neighbor borrow a ladder eight months ago and the neighbor hasn't returned it — they've hinted six times, the neighbor keeps saying 'oh yeah I'll bring it over,' and last week the caller saw the neighbor lending THEIR ladder to another neighbor and they're trying to figure out at what point they can just walk into the guy's garage and take it back without it being a crime", + "their best friend got a terrible tattoo and keeps asking if it looks good — it does not look good, it's a wolf howling at the moon but it looks like a dog having a seizure, it's on their forearm where everyone can see it, and now the friend wants the caller to get a matching one and the appointment is next Thursday", + "is in love with their best friend's wife and has been for eight years — nothing has ever happened, they've never said a word, but the friend just asked them to be the executor of his will and the person who takes care of his wife and kids if anything happens to him, and the caller said yes immediately and hates themselves for how fast they said it", + "secretly got a vasectomy two years ago and hasn't told their wife — she thinks they've been trying for a baby and he goes along with the fertility appointments and the ovulation tracking and watches her cry every month when the test is negative because he's too much of a coward to tell her he doesn't want another kid", + "found out their brother has been telling people their mother died to get sympathy and free things — their mother is alive and well and living in Tucson, and the brother has used the dead mom story to get out of speeding tickets, get upgraded at hotels, and get a month of free meals from a church group", + "has been going to the wrong therapist for three months — they mixed up the address at the first appointment, walked into a different practice, and the therapist never questioned it because the caller's name is close to an actual patient's — the therapy has been going great and they don't want to switch", + "is agonizing over whether to tell their coworker they've been calling another coworker by the wrong name for eleven months — the wrong-name coworker has started answering to it out of politeness and now half the office uses the wrong name too and correcting it would humiliate everyone involved", ] GOSSIP = [ @@ -1856,6 +1907,22 @@ GOSSIP = [ "their barber has a law degree and chose barbering because 'I'd rather talk to people honestly than argue for a living' — they found the diploma hanging in the back room", "found out the woman who runs the flower shop is a retired combat medic — she told the caller during a slow afternoon and the stories were nothing like what the caller expected", "their garbage collector has a PhD in environmental science — he took the job intentionally to study waste patterns and has published papers about suburban consumption", + # Comedy writer entries + "their youth pastor who preaches about sexual purity was just spotted leaving an adult bookstore off the highway at 1am — the caller knows because they were also leaving the adult bookstore and they locked eyes in the parking lot and now they have mutually assured destruction", + "found out the PTA mom who organized the 'family values' book banning campaign at school has an OnlyFans — a dad from another school district recognized her at a basketball game and showed the caller on his phone and it's not even a little bit ambiguous", + "their coworker who makes $55k a year just bought a $90k truck with cash and told everyone his grandmother died and left him money — the caller went to the grandmother's funeral three years ago because they're also friends with the family, and that grandmother had nothing", + "just found out their neighbor who puts up the biggest 'Support Our Troops' flag display every Fourth of July dodged the draft in the '70s by having his dentist write a letter about his teeth — the caller's father served two tours and lost a leg and the neighbor thanks him for his service every year at the block party", + "their friend's husband who lectures everyone about loyalty and commitment has a separate phone, a separate email, and a PO box — the caller knows because they share a mailman and the mailman let it slip after a few beers at the VFW", + "their neighbor who has a 'Live Laugh Love' sign in every room and posts daily gratitude affirmations screamed at a teenager at the Sonic drive-through until the kid cried — over a missing pickle", + "found out the guy at work who always talks about his 'lake house' has been sleeping in his car in the office parking garage three nights a week — security showed them the footage and he brings a pillow and everything", + "their town's most vocal anti-drinking city councilman was just pulled over for a DUI at 2pm on a Wednesday — in a neighboring town, driving a car registered to a woman who is not his wife, with an open container of Four Loko in the cupholder", + "their boss who fires people for being five minutes late has been leaving at 3pm every day for six months — the caller knows because they started parking behind the building and timing it, and they've got a spreadsheet going back to September", + "discovered that the woman in their neighborhood who runs a 'clean living' blog and sells essential oils for everything from headaches to infertility keeps a pack of Marlboro Reds in her glove compartment — the caller saw them when they helped her jump her car and she said 'those are my husband's' but her husband died in 2021", + "their coworker who talks constantly about their 'amazing marriage' on social media just got caught on the office security camera making out with the night janitor in the supply closet — the security guard showed the caller because the janitor is the caller's nephew", + "found out the man who runs the neighborhood watch and sends emails about 'suspicious activity' weekly has two outstanding warrants in another state — the caller's brother is a bail bondsman and recognized the name", + "their fitness influencer neighbor who posts shirtless transformation photos and sells a $200 meal plan eats McDonald's in his truck every night at 10pm — the caller can see the golden arches glow from across the street and has photo evidence on four separate occasions", + "just learned that the couple on their street who are always holding hands and posting anniversary tributes have been separated for a year — they keep up appearances because they co-own a wedding photography business and the brand depends on them looking happy", + "their coworker who brings elaborate homemade lunches every day and talks about their meal prep routine buys pre-made meals from the deli section at Whole Foods and transfers them into Tupperware in the parking lot — the caller watched the whole transfer through the break room window", ] PROBLEM_FILLS = { @@ -3476,6 +3543,22 @@ HOT_TAKES = [ "thinks white noise machines are just expensive fans and a ceiling fan does the exact same thing for free", "is fed up with restaurants that dim the lights so low you need your phone flashlight to read the menu", "believes the middle seat on a plane gets both armrests and this should be a federal law", + # Comedy writer entries + "thinks most people's dogs are poorly trained nightmares and the owners know it but saying anything about someone's dog is now treated like criticizing their child — and half the time the child is also a nightmare but at least the kid might grow out of it", + "is convinced that couples who say they 'never fight' are either lying or so dead inside they've stopped having opinions — healthy people disagree, and if you haven't told your partner they're wrong about something you don't respect them enough to be honest", + "believes the gym is the most dishonest place in America — everyone's pretending not to look at each other, pretending they know how to use the machines, pretending their music isn't too loud, and pretending they're not judging the person next to them who is absolutely doing that exercise wrong", + "thinks anyone who posts a picture of themselves crying on social media has never experienced a real emotion in their life — real grief doesn't need an audience and if your first instinct when something terrible happens is to open your front-facing camera you need a therapist not followers", + "is convinced that 'I'm not like other guys' is the most reliable indicator that a man is exactly like every other guy — the guys who are actually different never announce it because they don't know they're different, that's what makes them different", + "believes every man has a number — an amount of money where they'd do something they currently think is beneath them — and most men's number is a lot lower than they'd admit, and pretending otherwise is the biggest lie men tell themselves", + "thinks baby showers for a second kid should be illegal and the fact that people have the nerve to ask for gifts twice for doing the same thing is the kind of entitlement that's wrong with this country", + "is fed up with people who say 'money doesn't buy happiness' because it was clearly invented by someone who's never had to choose between gas and groceries — money absolutely buys happiness up to about a hundred grand and after that it buys a nicer version of the same unhappiness", + "believes the worst people at any barbecue are the ones who show up, eat everything, and then say 'I could have made this better' — if you could have, you would have, but you didn't, you brought a bag of ice and you should be grateful anyone invited you", + "thinks people who say 'I tell it like it is' are just rude people who found a way to brand their personality disorder as a virtue — telling it like it is would mean occasionally saying something nice and they never do", + "is adamant that the invention of the 'open floor plan' office was revenge by management on workers — nobody in history has ever done their best thinking while a coworker eats yogurt four feet from their face", + "believes people who post their gym routine on social media are compensating for a complete lack of personality — nobody who bench presses 225 needs to tell you about it, they just walk around looking like they bench 225 and that's enough", + "thinks the concept of a 'guilty pleasure' is cowardice — either you like something or you don't, and calling it guilty is just preemptively apologizing for having taste that someone might judge, and that's weaker than whatever you're watching", + "is convinced that 90% of people who say they 'love to cook' actually love to eat and tolerate cooking — the ones who really love cooking are weird about knives and have opinions about salt that nobody asked for", + "believes the worst invention of the 21st century isn't social media — it's the read receipt — because at least with social media you can pretend you didn't see it, but a read receipt is proof that someone looked at your message, understood it, and chose silence, which is violence", ] CELEBRATIONS = [ @@ -3637,6 +3720,17 @@ CELEBRATIONS = [ "their neighbor who's been battling depression for years came over with a pie and said 'I think I'm going to be okay' and meant it", "got a perfect score on a licensing exam they failed twice before — studied every night after the kids went to bed for four months", "their rescue dog who was afraid of everything finally played with another dog at the park today — tail wagging, full zoomies, the works", + # Comedy writer entries + "finally told their micromanaging boss to go to hell — didn't quit, didn't get fired, just said it in a meeting and the boss went quiet and now treats them with respect for the first time in four years and they realize they should have done it on day one", + "their ex who left them for someone 'more ambitious' just got fired and is delivering for DoorDash — the caller just got promoted to regional manager and ordered lunch through the app and guess who showed up at their office with a bag of pad thai", + "finally got their mother to admit that their aunt's potato salad is terrible and has always been terrible — thirty years of Thanksgivings vindicated in one sentence and they screamed in their truck in the driveway", + "their dad, who has never once complimented a meal in 65 years of life, said their brisket was 'not bad' and they're treating it like a James Beard Award because from this man that IS a James Beard Award", + "just won a small claims court case against their former landlord who kept their security deposit — the judge looked at the photos, looked at the landlord, and said 'you should be ashamed of yourself' and the caller said that sentence was worth more than the $1,800", + "won an argument with their spouse about whether you can make a left turn at a specific intersection — drove back to the intersection, pointed at the sign, and the spouse said 'huh, I guess you're right' which is the closest thing to a trophy this marriage has ever produced", + "got confirmed their vasectomy took and is celebrating the most underrated freedom a man can have — his wife is equally thrilled and they're going to take the money they'd been putting into a college fund and buy a bass boat", + "their neighbor who's been playing music at full volume every night for two years just got evicted — the caller watched the moving truck from their porch with a beer and it was the most satisfying thing they've experienced since their wedding day, and honestly it might have edged that out", + "caught a fish so big that nobody believes them even with the photo — their buddy said it was a 'forced perspective' and their wife said 'why does the fish look fake' and they've been defending this fish's honor for three weeks and they will not stop until justice is served", + "successfully lied about their age at a new job and has been getting away with it for two years — they're 52, everyone thinks they're 44, and when a coworker said 'you look amazing for your age' they just said 'good genes' and walked away feeling like a criminal mastermind", ] WEIRD = [ @@ -3750,6 +3844,15 @@ WEIRD = [ "their rain gauge collects exactly one inch of water every full moon even when it doesn't rain — they've cleaned it, moved it, replaced it", "found footprints on the inside of their attic window — the attic has no floor access except a pull-down ladder and the dust around it hasn't been disturbed", "their dog refuses to walk through one specific doorway in the house and has been going around through the kitchen instead for three weeks — the vet says the dog is fine", + # Comedy writer entries — funny-weird + "a man in business casual has been power-walking past their house at exactly 4:47 AM every morning for two years — they set an alarm to check and he's never missed a day, weekends and holidays included, rain or shine, and he's always carrying a single banana in his left hand", + "their dryer has been producing socks that don't belong to anyone in the household — not losing socks, GAINING socks — and they now have a drawer of nineteen mystery socks in sizes and styles nobody in the house wears and one of them is a tube sock with a corporate logo for a company that went out of business in 2004", + "someone has been leaving a single washed potato on their car windshield every Monday morning for four months — different potato each time, always scrubbed clean, always centered perfectly on the driver's side, never a note, never a footprint, and their security camera shows nothing between 2am and 5am even though the potato appears", + "every time they sneeze in their house, the neighbor's dog barks exactly twice — they've tested it forty-one times, had friends come over to verify, tried fake sneezes which don't trigger it, and it works with a 100% hit rate on genuine sneezes regardless of volume or time of day", + "their bathroom scale gives a different weight depending on which direction they face — not slightly different, consistently twelve pounds different — and they've tested it over two hundred times, bought a new scale that does the same thing in the same spot, and a third scale they put in the kitchen works normally", + "found a handwritten grocery list in their jacket pocket that isn't their handwriting — they live alone, the jacket has been in their closet for months, and the list includes items they've never bought but three of them are things they've been meaning to pick up and hadn't told anyone about", + "their late mother's perfume appears in the house on the anniversary of her death — no one wears it, the bottle was thrown out years ago, but every March 14th the bedroom smells exactly like her and by the next morning it's gone, and this year their kid who never met the grandmother walked in and said 'who's the lady'", + "a stray cat appears on their porch exactly one day before something goes wrong in their life — it showed up before they got fired, before their car broke down, before their pipe burst, and before their mother fell — it was on the porch again this morning and they're afraid to leave the house", ] LOCATIONS_LOCAL = [ @@ -4260,6 +4363,54 @@ CALLER_STYLES = [ "COMMUNICATION STYLE: Starts a sentence, gets distracted by their own tangent, starts another sentence, remembers the first one, tries to merge them. Asks 'where was I?' a lot. Not unintelligent — their brain just moves faster than their mouth. Lots of 'oh and another thing.' Energy level: medium-high but unfocused. When pushed back on, they agree enthusiastically and then immediately go off on another tangent. Conversational tendency: free association.", ] +# Short identifiers for each CALLER_STYLES entry (parallel list, same order). +# Used to look up STYLE_VOICE_PREFERENCES by index. +CALLER_STYLE_KEYS = [ + "quiet_nervous", # 0 + "storyteller", # 1 + "deadpan", # 2 + "high_energy", # 3 + "confrontational", # 4 + "oversharer", # 5 + "philosopher", # 6 + "bragger", # 7 + "first_time", # 8 + "emotional", # 9 + "world_weary", # 10 + "conspiracy", # 11 + "comedian", # 12 + "angry_venting", # 13 + "sweet_earnest", # 14 + "mysterious", # 15 + "know_it_all", # 16 + "rambling", # 17 +] + +# Preferred voice dimensions for each communication style. +# None = no preference (matcher can pick any value for that dimension). +# Maps style key → dict of preferred VOICE_PROFILES dimensions. +# Used by voice matching (Phase 2c) to score voices against caller personality. +STYLE_VOICE_PREFERENCES = { + "quiet_nervous": {"weight": "light", "energy": "low", "warmth": None, "age_feel": None}, + "storyteller": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": None}, + "deadpan": {"weight": "heavy", "energy": "low", "warmth": "cool", "age_feel": None}, + "high_energy": {"weight": None, "energy": "high", "warmth": "warm", "age_feel": "young"}, + "confrontational": {"weight": "heavy", "energy": "high", "warmth": "cool", "age_feel": None}, + "oversharer": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": None}, + "philosopher": {"weight": "heavy", "energy": "low", "warmth": "warm", "age_feel": "mature"}, + "bragger": {"weight": "heavy", "energy": "high", "warmth": "neutral", "age_feel": "middle"}, + "first_time": {"weight": "light", "energy": "low", "warmth": "warm", "age_feel": "young"}, + "emotional": {"weight": "medium", "energy": "low", "warmth": "warm", "age_feel": None}, + "world_weary": {"weight": "heavy", "energy": "low", "warmth": "cool", "age_feel": "mature"}, + "conspiracy": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"}, + "comedian": {"weight": "medium", "energy": "high", "warmth": "warm", "age_feel": None}, + "angry_venting": {"weight": "heavy", "energy": "high", "warmth": "neutral", "age_feel": None}, + "sweet_earnest": {"weight": "light", "energy": "medium", "warmth": "warm", "age_feel": None}, + "mysterious": {"weight": "heavy", "energy": "low", "warmth": "cool", "age_feel": "middle"}, + "know_it_all": {"weight": "medium", "energy": "medium", "warmth": "cool", "age_feel": "middle"}, + "rambling": {"weight": "light", "energy": "high", "warmth": "warm", "age_feel": None}, +} + # --- Call Shapes --- # Each shape defines the dramatic arc of a call. Weights control frequency. @@ -4279,9 +4430,56 @@ _CALL_SHAPE_NAMES = [s[0] for s in CALL_SHAPES] _CALL_SHAPE_WEIGHTS = [s[1] for s in CALL_SHAPES] -def _pick_call_shape() -> str: - """Pick a call shape using weighted random selection.""" - return random.choices(_CALL_SHAPE_NAMES, weights=_CALL_SHAPE_WEIGHTS, k=1)[0] +# Shape-style affinities: multipliers for base shape weights per communication style +SHAPE_STYLE_AFFINITIES = { + "quiet/nervous": {"the_hangup": 2.0, "escalating_reveal": 1.5, "bait_and_switch": 1.5, "confrontation": 0.3}, + "long-winded storyteller": {"escalating_reveal": 2.0, "bait_and_switch": 1.5, "standard": 1.5, "quick_hit": 0.3}, + "dry/deadpan": {"quick_hit": 1.5, "am_i_the_asshole": 1.5, "confrontation": 1.3}, + "high-energy": {"confrontation": 1.5, "celebration": 1.5, "reactive": 1.5, "the_hangup": 0.5}, + "confrontational": {"confrontation": 3.0, "reactive": 2.0, "am_i_the_asshole": 1.5, "celebration": 0.3}, + "oversharer": {"am_i_the_asshole": 2.0, "escalating_reveal": 1.5, "standard": 1.5}, + "working-class philosopher": {"standard": 1.5, "reactive": 1.5, "confrontation": 1.3}, + "bragger": {"am_i_the_asshole": 2.0, "confrontation": 1.5, "celebration": 1.5, "the_hangup": 0.3}, + "first-time caller": {"standard": 2.0, "the_hangup": 1.5, "quick_hit": 0.5}, + "emotional/raw": {"escalating_reveal": 2.0, "the_hangup": 1.5, "bait_and_switch": 1.5, "quick_hit": 0.3}, + "world-weary": {"standard": 1.5, "reactive": 1.5, "am_i_the_asshole": 1.3, "celebration": 0.3}, + "conspiracy-adjacent": {"escalating_reveal": 2.0, "bait_and_switch": 1.5, "confrontation": 1.3}, + "comedian": {"quick_hit": 2.0, "bait_and_switch": 1.5, "celebration": 1.3, "the_hangup": 0.3}, + "angry/venting": {"confrontation": 2.5, "reactive": 2.0, "the_hangup": 1.5, "celebration": 0.2}, + "sweet/earnest": {"celebration": 2.0, "standard": 1.5, "reactive": 1.3, "confrontation": 0.3}, + "mysterious/evasive": {"the_hangup": 2.5, "escalating_reveal": 2.0, "bait_and_switch": 1.5, "quick_hit": 0.3}, + "know-it-all": {"confrontation": 1.5, "am_i_the_asshole": 1.5, "reactive": 1.3}, + "rambling/scattered": {"bait_and_switch": 1.5, "escalating_reveal": 1.5, "standard": 1.3, "quick_hit": 0.3}, +} + + +def _pick_call_shape(style: str = "") -> str: + """Pick a call shape using weighted random selection. + If a communication style is provided, applies affinity multipliers. + Also avoids repeating the last used shape.""" + weights = list(_CALL_SHAPE_WEIGHTS) + + # Apply style affinities + if style: + style_key = style.split(":")[0].strip().lower() if ":" in style else style.lower() + affinities = SHAPE_STYLE_AFFINITIES.get(style_key, {}) + for i, name in enumerate(_CALL_SHAPE_NAMES): + if name in affinities: + weights[i] *= affinities[name] + + # Reduce weight of recently used shapes to avoid consecutive repeats + if hasattr(session, 'call_history') and session.call_history: + # Check if any recent call used this shape + recent_shapes = set() + for record in session.call_history[-2:]: + for k, v in session.caller_shapes.items(): + if CALLER_BASES.get(k, {}).get("name") == record.caller_name: + recent_shapes.add(v) + for i, name in enumerate(_CALL_SHAPE_NAMES): + if name in recent_shapes: + weights[i] *= 0.4 # Reduce but don't eliminate + + return random.choices(_CALL_SHAPE_NAMES, weights=weights, k=1)[0] def pick_location() -> str: @@ -4353,12 +4551,50 @@ def _generate_returning_caller_background(base: dict) -> str: trait_str = ", ".join(traits) if traits else "a regular caller" + # Use stored structured background for richer context + stored_bg = regular.get("structured_background") + if stored_bg and stored_bg.get("signature_detail"): + sig_detail = f"\nSIGNATURE DETAIL: {stored_bg['signature_detail']} — listeners remember this about you." + else: + sig_detail = "" + + # Include key moments from call history + key_moments_str = "" + all_moments = [] + for c in prev_calls[-3:]: + all_moments.extend(c.get("key_moments", [])) + if all_moments: + key_moments_str = f"\nMEMORABLE MOMENTS: {', '.join(all_moments[:4])}" + + # Arc status from most recent call + arc_note = "" + if prev_calls: + last_arc = prev_calls[-1].get("arc_status", "ongoing") + if last_arc == "resolved": + arc_note = "\nYour previous situation was resolved. You might be calling about something new, or a follow-up." + elif last_arc == "escalated": + arc_note = "\nYour situation has been getting worse. Things have escalated since your last call." + + # Relationship context with other regulars in this session + relationships = regular.get("relationships", {}) + rel_section = "" + if relationships: + active_names = {CALLER_BASES[k]["name"] for k in CALLER_BASES if "name" in CALLER_BASES[k]} + relevant = {name: rel for name, rel in relationships.items() if name in active_names} + if relevant: + rel_lines = [f"- {name}: {rel['context']}" for name, rel in relevant.items()] + rel_section = "\nPEOPLE YOU KNOW FROM THE SHOW:\n" + "\n".join(rel_lines) + parts = [ f"{age}, {job} {location}. Returning caller — {trait_str}.", f"\nRIGHT NOW: {time_ctx}", f"\nPEOPLE IN THEIR LIFE: {person1.capitalize()}. {person2.capitalize()}. Use their names when talking about them.", f"\nVERBAL HABITS: Tends to say \"{tic1}\" and \"{tic2}\" — use these naturally in conversation.", f"\nRELATIONSHIP TO THE SHOW: Has called before. Comfortable on air. Knows Luke by name.", + sig_detail, + key_moments_str, + arc_note, + rel_section, prev_section, ] @@ -4547,17 +4783,22 @@ def _pick_caller_style(reason: str, pool_name: str) -> str: def _assign_call_shape(base: dict) -> str: - """Pick and store a call shape for a caller, logging the assignment.""" - shape = _pick_call_shape() + """Pick and store a call shape for a caller, logging the assignment. + Uses style-based affinities when a communication style is assigned.""" + caller_key = None for key, b in CALLER_BASES.items(): if b is base or b.get("name") == base.get("name"): - session.caller_shapes[key] = shape - print(f"[Shape] {base.get('name', key)} assigned shape: {shape}") + caller_key = key break + style = session.caller_styles.get(caller_key, "") if caller_key else "" + shape = _pick_call_shape(style) + if caller_key: + session.caller_shapes[caller_key] = shape + print(f"[Shape] {base.get('name', caller_key)} assigned shape: {shape} (style: {style[:30]})") return shape -def generate_caller_background(base: dict) -> str: +def generate_caller_background(base: dict) -> CallerBackground | str: """Generate a template-based background as fallback. The preferred path is _generate_caller_background_llm() which produces more natural results.""" if base.get("returning") and base.get("regular_id"): @@ -4715,12 +4956,41 @@ def generate_caller_background(base: dict) -> str: if town_info: result += town_info - return result + # Determine energy level from style + _hi = {"high-energy", "confrontational", "angry/venting", "bragger", "comedian"} + _lo = {"quiet/nervous", "world-weary", "mysterious/evasive", "sweet/earnest", "emotional/raw"} + sl = style.split(":")[0].strip().lower() if ":" in style else style.lower() + if sl in _hi: + energy = random.choice(["high", "very_high"]) + elif sl in _lo: + energy = random.choice(["low", "medium"]) + else: + energy = random.choice(["medium", "high"]) + + return CallerBackground( + name=base["name"], + age=age, + gender=gender, + job=job, + location=location, + reason_for_calling=reason, + pool_name=pool_name, + communication_style=style, + energy_level=energy, + emotional_state="calm", + signature_detail=quirk1, + situation_summary=reason[:120], + natural_description=result, + seeds=[interest1, interest2, quirk1, opinion], + verbal_fluency="medium", + calling_from="", + ) -async def _generate_caller_background_llm(base: dict) -> str: +async def _generate_caller_background_llm(base: dict) -> CallerBackground | str: """Use LLM to write a natural character description from seed parameters. - Produces much more varied, natural-feeling backgrounds than the template approach.""" + Returns a CallerBackground with structured data + natural prose description. + Falls back to template on failure.""" if base.get("returning") and base.get("regular_id"): return generate_caller_background(base) # Returning callers use template + history @@ -4748,6 +5018,17 @@ async def _generate_caller_background_llm(base: dict) -> str: session.caller_styles[caller_key] = style style_hint = style.split(":")[1].strip()[:120] if ":" in style else "" + # Determine energy level from style + _high_energy_styles = {"high-energy", "confrontational", "angry/venting", "bragger", "comedian"} + _low_energy_styles = {"quiet/nervous", "world-weary", "mysterious/evasive", "sweet/earnest", "emotional/raw"} + style_label = style.split(":")[0].strip().lower() if ":" in style else style.lower() + if style_label in _high_energy_styles: + energy_level = random.choice(["high", "very_high"]) + elif style_label in _low_energy_styles: + energy_level = random.choice(["low", "medium"]) + else: + energy_level = random.choice(["medium", "high"]) + # Assign call shape _assign_call_shape(base) @@ -4800,7 +5081,7 @@ async def _generate_caller_background_llm(base: dict) -> str: }[fluency] location_line = f"\nLOCATION: {location}" if location else "" - prompt = f"""Write a brief character description for a caller on a late-night radio show set in the rural southwest (New Mexico/Arizona border region). Write it in third person as a character brief, not as dialog. + prompt = f"""Write a brief character description for a caller on a late-night radio show set in the rural southwest (New Mexico/Arizona border region). CALLER: {name}, {age}, {gender} JOB: {job}{location_line} @@ -4811,42 +5092,61 @@ TIME: {time_ctx} {season_ctx} {f'SOME DETAILS ABOUT THEM: {seed_text}' if seed_text else ''} {f'CALLER ENERGY: {style_hint}' if style_hint else ''} -Write 3-5 sentences describing this person. The "WHY THEY'RE CALLING" is the core of the character — build everything around it. Make it feel like a real person with a real situation, not a character sheet or therapy intake form. +Respond with a JSON object containing these fields: -WHAT MAKES A GOOD CALLER: The best radio callers have stories that are SPECIFIC, SURPRISING, and make you lean in. Think: absurd situations that escalated, moral dilemmas with no clean answer, petty feuds that got out of hand, workplace chaos, ridiculous coincidences, confessions that are funny and terrible at the same time, situations where the caller might be the villain and doesn't realize it. The kind of thing where the host says "wait, back up — say that again." +- "natural_description": 3-5 sentences describing this person in third person as a character brief. The "WHY THEY'RE CALLING" is the core — build everything around it. Make it feel like a real person with a real situation. Jump straight into the situation. What happened? What's the mess? Include where they're calling from (NOT always truck/porch — kitchens, break rooms, laundromats, diners, motel rooms, the gym, a bar, walking down the road, etc). +- "emotional_state": One word for how they're feeling right now (e.g. "nervous", "furious", "giddy", "defeated", "wired", "numb", "amused", "desperate", "smug"). +- "signature_detail": ONE specific memorable thing — a catchphrase, habit, running joke, strong opinion about something trivial, or unique life circumstance. The thing listeners would remember. +- "situation_summary": ONE sentence summarizing their situation that another caller could react to (e.g. "caught her neighbor stealing her mail and retaliated by stealing his garden gnomes"). +- "calling_from": Where they physically are right now (e.g. "kitchen table", "break room at the plant", "laundromat on 4th street", "parked outside Denny's"). -DO NOT WRITE: -- Generic revelation callers ("just found out [big secret]" — this format is BANNED) -- Adoption/DNA/paternity surprise stories -- Vague emotional processing ("carrying a weight," "sitting with this," "can't stop thinking about it") -- Therapy-speak ("processing," "unpacking," "my truth," "boundaries") -- The "sitting in their truck staring at nothing" opening -- Any version of "everything they thought they knew was a lie" +WHAT MAKES A GOOD CALLER: Stories that are SPECIFIC, SURPRISING, and make you lean in. Absurd situations, moral dilemmas, petty feuds, workplace chaos, ridiculous coincidences, funny+terrible confessions, callers who might be the villain and don't see it. -DO WRITE: Jump straight into the situation. What happened? What's the mess? What's the funny/terrible/absurd detail that makes this story worth telling on the radio? +DO NOT WRITE: Generic revelations, adoption/DNA/paternity surprises, vague emotional processing, therapy-speak, "sitting in truck staring at nothing," or "everything they thought they knew was a lie." -Vary where they're calling from. NOT everyone is in their truck or on the porch. Kitchens, break rooms, laundromats, diners, motel rooms, the bathtub, the gym, work, a bar, a hospital waiting room, walking down the road. - -Include ONE signature detail — a specific, memorable thing about this person that makes them instantly recognizable if they ever called back. A catchphrase, a distinctive habit, a running joke, a strong opinion about something trivial, or a unique life circumstance. This is the thing listeners would remember. - -Output ONLY the character description, nothing else.""" +Output ONLY valid JSON, no markdown fences.""" try: result = await llm_service.generate( messages=[{"role": "user", "content": prompt}], - max_tokens=200, + max_tokens=300, + response_format={"type": "json_object"}, ) result = result.strip() - # Sanity check — must mention the name or location - location_mentioned = location and location.split(",")[0].lower() in result.lower() - if len(result) > 50 and (name.lower() in result.lower() or location_mentioned): - result += f" {time_ctx} {season_ctx}" + parsed = json.loads(result) + natural_desc = parsed.get("natural_description", "").strip() + + # Sanity check + location_mentioned = location and location.split(",")[0].lower() in natural_desc.lower() + if len(natural_desc) > 50 and (name.lower() in natural_desc.lower() or location_mentioned): + natural_desc += f" {time_ctx} {season_ctx}" if town_info: - result += town_info - print(f"[Background] LLM-generated for {name}: {result[:80]}...") - return result + natural_desc += town_info + + bg = CallerBackground( + name=name, + age=age, + gender=gender, + job=job, + location=location, + reason_for_calling=reason, + pool_name=pool_name, + communication_style=style, + energy_level=energy_level, + emotional_state=parsed.get("emotional_state", "calm"), + signature_detail=parsed.get("signature_detail", ""), + situation_summary=parsed.get("situation_summary", reason[:100]), + natural_description=natural_desc, + seeds=seeds, + verbal_fluency=fluency, + calling_from=parsed.get("calling_from", ""), + ) + print(f"[Background] LLM-generated for {name}: {natural_desc[:80]}...") + return bg else: print(f"[Background] LLM output didn't pass sanity check for {name}, falling back to template") + except json.JSONDecodeError as e: + print(f"[Background] JSON parse failed for {name}: {e}, falling back to template") except Exception as e: print(f"[Background] LLM generation failed for {name}: {e}") @@ -4871,6 +5171,263 @@ async def _pregenerate_backgrounds(): print(f"[Background] Pre-generated {len(session.caller_backgrounds)} caller backgrounds") + # Re-assign voices to match caller styles + _match_voices_to_styles() + + # Sort caller presentation order for good show pacing + _sort_caller_queue() + + # Build relationship context for regulars who know each other + _build_relationship_context() + + +# Dramatic shapes that play better later in the show +_LATE_SHOW_SHAPES = {"escalating_reveal", "bait_and_switch", "the_hangup"} + + +def _sort_caller_queue(): + """Sort caller presentation order for good show pacing. + Does NOT change which callers exist — only the order they're presented. + Prioritizes: energy alternation, topic variety, shape variety, + dramatic shapes later in the show.""" + keys = list(session.caller_backgrounds.keys()) + if not keys: + return + + # Gather attributes for each caller + caller_attrs = {} + for key in keys: + bg = session.caller_backgrounds.get(key) + if isinstance(bg, CallerBackground): + energy = bg.energy_level + pool = bg.pool_name + else: + energy = "medium" + pool = "" + shape = session.caller_shapes.get(key, "standard") + caller_attrs[key] = {"energy": energy, "pool": pool, "shape": shape} + + # Greedy placement: pick the best next caller at each position + remaining = list(keys) + ordered = [] + + for position in range(len(keys)): + best_key = None + best_score = -999 + + for key in remaining: + attrs = caller_attrs[key] + score = 0.0 + + # Energy alternation: penalize same energy as previous caller + if ordered: + prev_energy = caller_attrs[ordered[-1]]["energy"] + if attrs["energy"] == prev_energy: + score -= 3.0 + # Bonus for contrast + high = {"high", "very_high"} + low = {"low", "medium"} + if (attrs["energy"] in high and prev_energy in low) or \ + (attrs["energy"] in low and prev_energy in high): + score += 2.0 + + # Topic variety: penalize same pool as previous caller + if ordered: + prev_pool = caller_attrs[ordered[-1]]["pool"] + if attrs["pool"] and attrs["pool"] == prev_pool: + score -= 3.0 + # Also check 2-back + if len(ordered) >= 2: + prev2_pool = caller_attrs[ordered[-2]]["pool"] + if attrs["pool"] and attrs["pool"] == prev2_pool: + score -= 1.5 + + # Shape variety: penalize same shape as previous caller + if ordered: + prev_shape = caller_attrs[ordered[-1]]["shape"] + if attrs["shape"] == prev_shape: + score -= 2.0 + + # Dramatic shapes: boost for later positions (7-10) + if attrs["shape"] in _LATE_SHOW_SHAPES: + if position >= 6: # positions 7-10 (0-indexed 6-9) + score += 3.0 + elif position <= 2: # too early + score -= 2.0 + + if score > best_score: + best_score = score + best_key = key + + ordered.append(best_key) + remaining.remove(best_key) + + session.caller_queue = ordered + queue_summary = ", ".join( + f"{CALLER_BASES.get(k, {}).get('name', k)}({caller_attrs[k]['energy'][0]}/{caller_attrs[k]['pool'][:4] if caller_attrs[k]['pool'] else '?'}/{caller_attrs[k]['shape'][:4]})" + for k in ordered + ) + print(f"[Pacing] Caller queue: {queue_summary}") + + +def _build_relationship_context(): + """Find regulars with existing relationships who are both in the current session. + Inject mutual awareness into both callers' prompts.""" + regulars = regular_caller_service.get_regulars() + if not regulars: + return + + # Map regular names to their caller keys in this session + name_to_key = {} + key_to_regular = {} + for key, base in CALLER_BASES.items(): + if base.get("returning") and base.get("regular_id"): + for reg in regulars: + if reg["id"] == base["regular_id"]: + name_to_key[reg["name"]] = key + key_to_regular[key] = reg + break + + if len(name_to_key) < 2: + return # Need at least 2 regulars to have relationships + + # Check for mutual relationships + for key, regular in key_to_regular.items(): + relationships = regular.get("relationships", {}) + for other_name, rel_info in relationships.items(): + if other_name in name_to_key: + other_key = name_to_key[other_name] + rel_type = rel_info.get("type", "knows") + context = rel_info.get("context", "") + # Inject awareness into this caller's prompt + line = f"\nSOMEONE YOU KNOW IS ON THE SHOW TONIGHT: {other_name} is also calling in. You know them — {rel_type}. {context} You might hear them on air. If Luke mentions them or you hear them, react naturally. Don't force it — if it comes up, it comes up." + existing = session.relationship_context.get(key, "") + session.relationship_context[key] = existing + line + print(f"[Relationships] {regular['name']} knows {other_name} ({rel_type})") + + +# Style-based TTS speed modifiers — stacks with per-voice and per-utterance adjustments +STYLE_SPEED_MODIFIERS = { + "quiet_nervous": -0.1, + "first_time": -0.08, + "emotional": -0.1, + "world_weary": -0.15, + "philosopher": -0.08, + "storyteller": -0.05, + "high_energy": +0.1, + "confrontational": +0.08, + "angry_venting": +0.08, + "rambling": +0.05, + "comedian": +0.05, +} + +# Style-based phone filter quality +STYLE_PHONE_QUALITY = { + "quiet_nervous": "bad", + "mysterious": "bad", + "world_weary": "bad", + "conspiracy": "bad", + "high_energy": "good", + "confrontational": "good", + "bragger": "good", + "comedian": "good", +} + + +def _normalize_style_key(style: str) -> str: + """Convert a full style string like 'Quiet/Nervous: Short sentences...' to a key like 'quiet_nervous'.""" + label = style.split(":")[0].strip().lower() if ":" in style else style.lower() + key_map = { + "quiet/nervous": "quiet_nervous", + "long-winded storyteller": "storyteller", + "dry/deadpan": "deadpan", + "high-energy": "high_energy", + "confrontational": "confrontational", + "oversharer": "oversharer", + "working-class philosopher": "philosopher", + "bragger": "bragger", + "first-time caller": "first_time", + "emotional/raw": "emotional", + "world-weary": "world_weary", + "conspiracy-adjacent": "conspiracy", + "comedian": "comedian", + "angry/venting": "angry_venting", + "sweet/earnest": "sweet_earnest", + "mysterious/evasive": "mysterious", + "know-it-all": "know_it_all", + "rambling/scattered": "rambling", + } + return key_map.get(label, label) + + +def _match_voices_to_styles(): + """Re-assign voices to match caller communication styles after backgrounds are generated.""" + from .services.tts import VOICE_PROFILES + + for key, base in CALLER_BASES.items(): + if base.get("returning"): + continue + + style_raw = session.caller_styles.get(key, "") + if not style_raw: + continue + + style_key = _normalize_style_key(style_raw) + prefs = STYLE_VOICE_PREFERENCES.get(style_key) + if not prefs: + continue + + gender = base["gender"] + voice_pool = list(INWORLD_MALE_VOICES if gender == "male" else INWORLD_FEMALE_VOICES) + + scored = [] + for voice_name in voice_pool: + profile = VOICE_PROFILES.get(voice_name) + if not profile: + scored.append((voice_name, 0)) + continue + score = 0 + for dim in ["weight", "energy", "warmth", "age_feel"]: + pref_val = prefs.get(dim) + if pref_val and profile.get(dim) == pref_val: + score += 1 + scored.append((voice_name, score)) + + if scored: + names = [s[0] for s in scored] + weights = [max(1, s[1] * 3) for s in scored] + chosen = random.choices(names, weights=weights, k=1)[0] + + used_voices = {CALLER_BASES[k]["voice"] for k in CALLER_BASES if k != key and "voice" in CALLER_BASES[k]} + if chosen in used_voices: + alternatives = [(n, w) for n, w in zip(names, weights) if n not in used_voices] + if alternatives: + alt_names, alt_weights = zip(*alternatives) + chosen = random.choices(alt_names, weights=alt_weights, k=1)[0] + + old_voice = base.get("voice", "") + base["voice"] = chosen + if old_voice != chosen: + print(f"[VoiceMatch] {base.get('name', key)}: {old_voice} → {chosen} (style: {style_key})") + + +def get_style_speed_modifier(caller_key: str) -> float: + """Get the TTS speed modifier for a caller based on their communication style.""" + style_raw = session.caller_styles.get(caller_key, "") + if not style_raw: + return 0.0 + style_key = _normalize_style_key(style_raw) + return STYLE_SPEED_MODIFIERS.get(style_key, 0.0) + + +def get_style_phone_quality(caller_key: str) -> str | None: + """Get the phone filter quality override for a caller based on their style.""" + style_raw = session.caller_styles.get(caller_key, "") + if not style_raw: + return None + style_key = _normalize_style_key(style_raw) + return STYLE_PHONE_QUALITY.get(style_key) + # Known topics for smarter search queries — maps keywords in backgrounds to search terms _TOPIC_SEARCH_MAP = [ @@ -5023,8 +5580,11 @@ async def enrich_caller_background(background: str) -> str: return background -def detect_host_mood(messages: list[dict]) -> str: +def detect_host_mood(messages: list[dict], wrapping_up: bool = False) -> str: """Analyze recent host messages to detect mood signals for caller adaptation.""" + if wrapping_up: + return "\nEMOTIONAL READ ON THE HOST:\n- The host is DONE with this call. Give a SHORT goodbye — one sentence max. Do not introduce new topics.\n" + host_msgs = [m["content"] for m in messages if m.get("role") in ("user", "host")][-5:] if not host_msgs: return "" @@ -5199,7 +5759,8 @@ Don't just comment on the previous caller like a pundit. Have skin in the game. def get_caller_prompt(caller: dict, show_history: str = "", news_context: str = "", research_context: str = "", - emotional_read: str = "") -> str: + emotional_read: str = "", + relationship_context: str = "") -> str: """Generate a natural system prompt for a caller. Note: conversation history is passed as actual LLM messages, not duplicated here.""" @@ -5263,7 +5824,7 @@ You are {caller['name']}. You are the CALLER. You are NOT Luke. Luke is the HOST YOUR BACKGROUND: {caller['vibe']} -{history}{world_context}{emotional_read} +{relationship_context}{history}{world_context}{emotional_read} You're a real person calling a late-night radio show. You called because you've got something specific and you want to talk about it. {pacing_block} @@ -5291,6 +5852,27 @@ BANNED PHRASES — never use these: "that hit differently," "hits different," "I NEVER mention minors in sexual context. Output spoken words only — no parenthetical actions like (laughs) or (sighs), no asterisk actions like *pauses*, no stage directions, no gestures. Just say what you'd actually say out loud on the phone. Use "United States" not "US" or "USA". Use full state names not abbreviations.""" +# --- Structured Caller Background --- +@dataclass +class CallerBackground: + name: str + age: int + gender: str + job: str + location: str | None + reason_for_calling: str + pool_name: str + communication_style: str + energy_level: str # low / medium / high / very_high + emotional_state: str # nervous, excited, angry, vulnerable, calm, etc. + signature_detail: str # The memorable thing about them + situation_summary: str # 1-sentence summary for other callers to reference + natural_description: str # 3-5 sentence prose for the prompt + seeds: list[str] = field(default_factory=list) + verbal_fluency: str = "medium" + calling_from: str = "" + + # --- Session State --- @dataclass class CallRecord: @@ -5300,6 +5882,14 @@ class CallRecord: transcript: list[dict] = field(default_factory=list) started_at: float = 0.0 ended_at: float = 0.0 + quality_signals: dict = field(default_factory=dict) # Per-call quality heuristics + # Inter-caller awareness fields (populated from CallerBackground) + topic_category: str = "" # Pool name: PROBLEMS, STORIES, etc. + situation_summary: str = "" # 1-sentence summary for other callers + emotional_state: str = "" # How the caller was feeling + energy_level: str = "" # low/medium/high/very_high + communication_style: str = "" # Style key + key_details: list[str] = field(default_factory=list) # Specific memorable details def _serialize_call_record(record: CallRecord) -> dict: @@ -5310,6 +5900,13 @@ def _serialize_call_record(record: CallRecord) -> dict: "transcript": record.transcript, "started_at": record.started_at, "ended_at": record.ended_at, + "quality_signals": record.quality_signals, + "topic_category": record.topic_category, + "situation_summary": record.situation_summary, + "emotional_state": record.emotional_state, + "energy_level": record.energy_level, + "communication_style": record.communication_style, + "key_details": record.key_details, } @@ -5321,15 +5918,62 @@ def _deserialize_call_record(data: dict) -> CallRecord: transcript=data.get("transcript", []), started_at=data.get("started_at", 0.0), ended_at=data.get("ended_at", 0.0), + quality_signals=data.get("quality_signals", {}), + topic_category=data.get("topic_category", ""), + situation_summary=data.get("situation_summary", ""), + emotional_state=data.get("emotional_state", ""), + energy_level=data.get("energy_level", ""), + communication_style=data.get("communication_style", ""), + key_details=data.get("key_details", []), ) +def _assess_call_quality( + conversation: list[dict], + caller_hangup: bool = False, + shape: str = "", + style: str = "", + pool_name: str = "", +) -> dict: + """Compute heuristic quality signals for a completed call. No LLM needed. + Returns a plain dict for storage in CallRecord.quality_signals and session.call_quality_signals.""" + host_msgs = [m for m in conversation if m.get("role") in ("user", "host")] + caller_msgs = [m for m in conversation if m.get("role") == "assistant"] + + exchange_count = len(conversation) + + caller_char_counts = [len(m["content"]) for m in caller_msgs] + avg_response_length = ( + round(sum(caller_char_counts) / len(caller_char_counts), 1) + if caller_char_counts else 0.0 + ) + + host_engagement = sum(1 for m in host_msgs if "?" in m["content"]) + + # Caller depth: responses > 50 chars after the first exchange + caller_depth = sum(1 for m in caller_msgs[1:] if len(m["content"]) > 50) + + # Natural ending: True if the call did NOT end with [HANGUP] sentinel + natural_ending = not caller_hangup + + return { + "exchange_count": exchange_count, + "avg_response_length": avg_response_length, + "host_engagement": host_engagement, + "caller_depth": caller_depth, + "natural_ending": natural_ending, + "shape": shape, + "style": style, + "pool_name": pool_name, + } + + class Session: def __init__(self): self.id = str(uuid.uuid4())[:8] self.current_caller_key: str = None self.conversation: list[dict] = [] - self.caller_backgrounds: dict[str, str] = {} # Generated backgrounds for this session + self.caller_backgrounds: dict[str, CallerBackground | str] = {} # Generated backgrounds self.call_history: list[CallRecord] = [] self._call_started_at: float = 0.0 self.active_real_caller: dict | None = None @@ -5343,11 +5987,21 @@ class Session: self.caller_styles: dict[str, str] = {} self.caller_shapes: dict[str, str] = {} self.tone_streak: list[str] = [] # Track tone per call for variety balancing + self.call_quality_signals: list[dict] = [] # Per-call quality heuristics for tuning + self._caller_hangup: bool = False # Set when [HANGUP] sentinel detected in current call + self._wrapping_up: bool = False # Set via /api/wrap-up to gracefully wind down calls + self._wrapup_exchanges: int = 0 # Track how many exchanges since wrap-up started + self.caller_queue: list[str] = [] # Sorted presentation order of caller keys + self.relationship_context: dict[str, str] = {} # caller_key → relationship prompt injection + self.intern_monitoring: bool = True # Devon monitors conversations by default def start_call(self, caller_key: str): self.current_caller_key = caller_key self.conversation = [] self._call_started_at = time.time() + self._caller_hangup = False + self._wrapping_up = False + self._wrapup_exchanges = 0 def end_call(self): self.current_caller_key = None @@ -5357,17 +6011,21 @@ class Session: self.conversation.append({"role": role, "content": content, "timestamp": time.time()}) def get_caller_background(self, caller_key: str) -> str: - """Get or generate background for a caller in this session""" + """Get or generate background for a caller in this session. + Returns the natural_description string for prompt injection.""" if caller_key not in self.caller_backgrounds: base = CALLER_BASES.get(caller_key) if base: self.caller_backgrounds[caller_key] = generate_caller_background(base) - print(f"[Session {self.id}] Generated background for {base['name']}: {self.caller_backgrounds[caller_key][:100]}...") - return self.caller_backgrounds.get(caller_key, "") + bg = self.caller_backgrounds[caller_key] + desc = bg.natural_description if isinstance(bg, CallerBackground) else bg + print(f"[Session {self.id}] Generated background for {base['name']}: {desc[:100]}...") + bg = self.caller_backgrounds.get(caller_key, "") + return bg.natural_description if isinstance(bg, CallerBackground) else bg def get_show_history(self) -> str: """Get formatted show history for AI caller prompts. - Randomly picks one previous caller to have a strong reaction to.""" + Uses thematic matching to pick relevant previous callers to react to.""" if not self.call_history and not any(e.read_on_air for e in _listener_emails): return "" lines = ["EARLIER IN THE SHOW:"] @@ -5382,19 +6040,119 @@ class Session: preview = em.body[:150] if len(em.body) > 150 else em.body lines.append(f"- A listener email from {sender_name} was read on air: \"{em.subject}\" — {preview}") - # 35% chance to have a reaction to a previous caller (with intensity levels) - if self.call_history and random.random() < 0.35: - target = random.choice(self.call_history) - reaction = random.choice(SHOW_HISTORY_REACTIONS) - # 30% driven_by (strong, shapes the call), 70% mention (passing reference) - if random.random() < 0.30: - lines.append(f"\nYOU HEARD {target.caller_name.upper()} EARLIER and you {reaction}. This is partly why you called — bring it up early and tie it into your story.") + # Thematic matching for inter-caller reactions + if self.call_history: + current_bg = self.caller_backgrounds.get(self.current_caller_key) + best_target, best_score = self._find_thematic_match(current_bg) + + # Adaptive reaction frequency based on thematic match strength + if best_score >= 3: + reaction_chance = 0.60 + elif best_score >= 1: + reaction_chance = 0.35 else: - lines.append(f"\nYOU HEARD {target.caller_name.upper()} EARLIER and you {reaction}. Mention it if it comes up naturally, but your call is about YOUR thing.") - else: - lines.append("You're aware of these but you're calling about YOUR thing, not theirs. Don't bring them up unless the host does.") + reaction_chance = 0.15 + + if random.random() < reaction_chance and best_target: + reaction = self._build_specific_reaction(current_bg, best_target) + if random.random() < 0.30: + lines.append(f"\nYOU HEARD {best_target.caller_name.upper()} EARLIER and you {reaction}. This is partly why you called — bring it up early and tie it into your story.") + else: + lines.append(f"\nYOU HEARD {best_target.caller_name.upper()} EARLIER and you {reaction}. Mention it if it comes up naturally, but your call is about YOUR thing.") + else: + lines.append("You're aware of these but you're calling about YOUR thing, not theirs. Don't bring them up unless the host does.") + + # Show energy tracking + energy_note = self._get_show_energy() + if energy_note: + lines.append(f"\n{energy_note}") + return "\n".join(lines) + def _find_thematic_match(self, current_bg) -> tuple: + """Score previous callers against current caller for thematic relevance. + Returns (best_target CallRecord, score).""" + if not self.call_history: + return None, 0 + + best_target = None + best_score = 0 + + current_pool = current_bg.pool_name if isinstance(current_bg, CallerBackground) else "" + current_reason = current_bg.reason_for_calling if isinstance(current_bg, CallerBackground) else "" + current_summary = current_bg.situation_summary if isinstance(current_bg, CallerBackground) else "" + current_words = set((current_reason + " " + current_summary).lower().split()) + + for record in self.call_history: + score = 0 + # Same topic pool = strong match + if current_pool and record.topic_category == current_pool: + score += 2 + # Keyword overlap in situation summaries + if record.situation_summary: + record_words = set(record.situation_summary.lower().split()) + overlap = current_words & record_words - {"the", "a", "an", "and", "or", "is", "was", "to", "in", "of", "for", "that", "it", "on", "with"} + if len(overlap) >= 2: + score += 2 + elif len(overlap) >= 1: + score += 1 + # Emotional contrast bonus (opposite energies are interesting) + if record.energy_level and isinstance(current_bg, CallerBackground): + if (record.energy_level in ("low", "medium") and current_bg.energy_level in ("high", "very_high")) or \ + (record.energy_level in ("high", "very_high") and current_bg.energy_level in ("low", "medium")): + score += 1 + + if score > best_score: + best_score = score + best_target = record + + # If no thematic match, pick a random target for generic reactions + if best_target is None: + best_target = random.choice(self.call_history) + + return best_target, best_score + + def _build_specific_reaction(self, current_bg, target: 'CallRecord') -> str: + """Build a reaction that references specific details from the target call.""" + # If target has specific details, use them for a more specific reaction + if target.key_details: + detail = random.choice(target.key_details) + specific_reactions = [ + f"heard them talk about {detail} and has strong opinions about it", + f"had something similar happen involving {detail}", + f"completely disagrees with their take on {detail}", + f"was thinking about what they said about {detail} and it reminded them of their own situation", + f"can't stop thinking about the {detail} part", + ] + return random.choice(specific_reactions) + + # If target has a situation summary, use that + if target.situation_summary: + summary_reactions = [ + f"heard about their situation and has been through something eerily similar", + f"thinks they were completely wrong about their situation", + f"felt personally called out by their story", + f"wants to give them advice the host didn't", + ] + return random.choice(summary_reactions) + + # Fallback to generic reactions + return random.choice(SHOW_HISTORY_REACTIONS) + + def _get_show_energy(self) -> str: + """Summarize the energy arc of the show for caller awareness.""" + if len(self.call_history) < 3: + return "" + recent = self.call_history[-3:] + energies = [r.energy_level for r in recent if r.energy_level] + if not energies: + return "" + if all(e in ("high", "very_high") for e in energies): + return "SHOW ENERGY: The last few calls have been high-energy — the show could use a breather." + if all(e in ("low", "medium") for e in energies): + return "SHOW ENERGY: The last few calls have been mellow — some energy would shake things up." + return "" + def get_conversation_summary(self) -> str: """Get a brief summary of conversation so far for context""" if len(self.conversation) <= 2: @@ -5452,7 +6210,15 @@ class Session: self.caller_styles = {} self.caller_shapes = {} self.tone_streak = [] + self.call_quality_signals = [] + self._wrapping_up = False + self._wrapup_exchanges = 0 + self.caller_queue = [] + self.relationship_context = {} self.used_reasons = set() + self.intern_monitoring = True + intern_service.stop_monitoring() + intern_service.dismiss_suggestion() _randomize_callers() self.id = str(uuid.uuid4())[:8] names = [CALLER_BASES[k]["name"] for k in sorted(CALLER_BASES.keys())] @@ -5582,7 +6348,7 @@ def _save_checkpoint(): data = { "session_id": session.id, "call_history": [_serialize_call_record(r) for r in session.call_history], - "caller_backgrounds": session.caller_backgrounds, + "caller_backgrounds": {k: asdict(v) if isinstance(v, CallerBackground) else v for k, v in session.caller_backgrounds.items()}, "used_reasons": list(session.used_reasons), "ai_respond_mode": session.ai_respond_mode, "auto_followup": session.auto_followup, @@ -5593,6 +6359,10 @@ def _save_checkpoint(): "caller_styles": session.caller_styles, "caller_shapes": session.caller_shapes, "tone_streak": session.tone_streak, + "call_quality_signals": session.call_quality_signals, + "caller_queue": session.caller_queue, + "relationship_context": session.relationship_context, + "intern_monitoring": session.intern_monitoring, "saved_at": time.time(), } with open(CHECKPOINT_FILE, "w") as f: @@ -5614,7 +6384,13 @@ def _load_checkpoint() -> bool: return False session.id = data["session_id"] session.call_history = [_deserialize_call_record(r) for r in data.get("call_history", [])] - session.caller_backgrounds = data.get("caller_backgrounds", {}) + raw_bgs = data.get("caller_backgrounds", {}) + session.caller_backgrounds = {} + for k, v in raw_bgs.items(): + if isinstance(v, dict) and "natural_description" in v: + session.caller_backgrounds[k] = CallerBackground(**v) + else: + session.caller_backgrounds[k] = v session.used_reasons = set(data.get("used_reasons", [])) session.ai_respond_mode = data.get("ai_respond_mode", "manual") session.auto_followup = data.get("auto_followup", False) @@ -5624,6 +6400,10 @@ def _load_checkpoint() -> bool: session.caller_styles = data.get("caller_styles", {}) session.caller_shapes = data.get("caller_shapes", {}) session.tone_streak = data.get("tone_streak", []) + session.call_quality_signals = data.get("call_quality_signals", []) + session.caller_queue = data.get("caller_queue", []) + session.relationship_context = data.get("relationship_context", {}) + session.intern_monitoring = data.get("intern_monitoring", True) for key, snapshot in data.get("caller_bases", {}).items(): if key in CALLER_BASES: CALLER_BASES[key]["name"] = snapshot["name"] @@ -6573,12 +7353,26 @@ async def stop_recording(): @app.get("/api/callers") async def get_callers(): - """Get list of available callers""" + """Get list of available callers with background info for UI display""" + callers = [] + for k, v in CALLER_BASES.items(): + caller_info = { + "key": k, + "name": v["name"], + "returning": v.get("returning", False), + } + bg = session.caller_backgrounds.get(k) + if isinstance(bg, CallerBackground): + caller_info["energy_level"] = bg.energy_level + caller_info["emotional_state"] = bg.emotional_state + caller_info["communication_style"] = _normalize_style_key(bg.communication_style) + caller_info["signature_detail"] = bg.signature_detail + caller_info["situation_summary"] = bg.situation_summary + caller_info["pool_name"] = bg.pool_name + caller_info["call_shape"] = session.caller_shapes.get(k, "standard") + callers.append(caller_info) return { - "callers": [ - {"key": k, "name": v["name"], "returning": v.get("returning", False)} - for k, v in CALLER_BASES.items() - ], + "callers": callers, "current": session.current_caller_key, "session_id": session.id } @@ -6646,7 +7440,10 @@ async def start_call(caller_key: str): if callback: existing_bg = session.caller_backgrounds.get(caller_key, "") callback_ctx = f"\n\nCALLBACK: You already called earlier tonight. {callback['callback_reason']}. Reference your earlier call naturally — you're a returning caller with an update." - session.caller_backgrounds[caller_key] = existing_bg + callback_ctx + if isinstance(existing_bg, CallerBackground): + existing_bg.natural_description += callback_ctx + else: + session.caller_backgrounds[caller_key] = existing_bg + callback_ctx print(f"[Callback] Injected callback context for {CALLER_BASES[caller_key].get('name', caller_key)}") caller = session.caller # This generates the background if needed @@ -6655,18 +7452,46 @@ async def start_call(caller_key: str): if caller_key in session.caller_backgrounds: asyncio.create_task(_enrich_background_async(caller_key)) + # Extract CallerBackground structured data if available + bg = session.caller_backgrounds.get(caller_key) + caller_info = {} + if isinstance(bg, CallerBackground): + caller_info = { + "emotional_state": bg.emotional_state, + "energy_level": bg.energy_level, + "signature_detail": bg.signature_detail, + "situation_summary": bg.situation_summary, + "call_shape": caller.get("shape", "standard"), + "communication_style": bg.communication_style, + } + + # Start intern monitoring if enabled + if session.intern_monitoring and not intern_service.monitoring: + async def _on_intern_suggestion(text, sources): + broadcast_event("intern_suggestion", {"text": text, "sources": sources}) + intern_service.start_monitoring( + get_conversation=lambda: session.conversation, + on_suggestion=_on_intern_suggestion, + ) + return { "status": "connected", "caller": caller["name"], - "background": caller["vibe"] # Send background so you can see who you're talking to + "background": caller["vibe"], + "caller_info": caller_info, } async def _enrich_background_async(caller_key: str): """Enrich caller background with news/weather without blocking the call""" try: - enriched = await enrich_caller_background(session.caller_backgrounds[caller_key]) - session.caller_backgrounds[caller_key] = enriched + bg = session.caller_backgrounds[caller_key] + bg_text = bg.natural_description if isinstance(bg, CallerBackground) else bg + enriched = await enrich_caller_background(bg_text) + if isinstance(bg, CallerBackground): + bg.natural_description = enriched + else: + session.caller_backgrounds[caller_key] = enriched except Exception as e: print(f"[Research] Background enrichment failed: {e}") @@ -6690,10 +7515,16 @@ async def hangup(): session._research_task.cancel() session._research_task = None + # Stop intern monitoring between calls + intern_service.stop_monitoring() + caller_name = session.caller["name"] if session.caller else None caller_key = session.current_caller_key conversation_snapshot = list(session.conversation) call_started = getattr(session, '_call_started_at', 0.0) + was_caller_hangup = session._caller_hangup + session._wrapping_up = False + session._wrapup_exchanges = 0 session.end_call() # Play hangup sound in background so response returns immediately @@ -6703,12 +7534,23 @@ async def hangup(): # Generate summary for AI caller in background if caller_name and conversation_snapshot: - asyncio.create_task(_summarize_ai_call(caller_key, caller_name, conversation_snapshot, call_started)) + asyncio.create_task(_summarize_ai_call(caller_key, caller_name, conversation_snapshot, call_started, was_caller_hangup)) return {"status": "disconnected", "caller": caller_name} -async def _summarize_ai_call(caller_key: str, caller_name: str, conversation: list[dict], started_at: float): +@app.post("/api/wrap-up") +async def wrap_up(): + """Signal the current caller to wrap up gracefully""" + if not session.caller: + raise HTTPException(400, "No active call") + session._wrapping_up = True + session._wrapup_exchanges = 0 + print(f"[Wrap-up] Initiated for {session.caller['name']}") + return {"status": "wrapping_up"} + + +async def _summarize_ai_call(caller_key: str, caller_name: str, conversation: list[dict], started_at: float, caller_hangup: bool = False): """Background task: summarize AI caller conversation and store in history""" ended_at = time.time() summary = "" @@ -6725,6 +7567,33 @@ async def _summarize_ai_call(caller_key: str, caller_name: str, conversation: li print(f"[AI Summary] Failed to generate summary: {e}") summary = f"{caller_name} called in." + # Extract structured data from CallerBackground for inter-caller awareness + bg = session.caller_backgrounds.get(caller_key) + if isinstance(bg, CallerBackground): + topic_cat = bg.pool_name + sit_summary = bg.situation_summary + emo_state = bg.emotional_state + energy = bg.energy_level + comm_style = bg.communication_style + key_dets = [bg.signature_detail] if bg.signature_detail else [] + else: + topic_cat = "" + sit_summary = "" + emo_state = "" + energy = "" + comm_style = session.caller_styles.get(caller_key, "") + key_dets = [] + + call_shape = session.caller_shapes.get(caller_key, "standard") + quality_signals = _assess_call_quality( + conversation, + caller_hangup=caller_hangup, + shape=call_shape, + style=comm_style, + pool_name=topic_cat, + ) + session.call_quality_signals.append(quality_signals) + session.call_history.append(CallRecord( caller_type="ai", caller_name=caller_name, @@ -6732,8 +7601,16 @@ async def _summarize_ai_call(caller_key: str, caller_name: str, conversation: li transcript=conversation, started_at=started_at, ended_at=ended_at, + quality_signals=quality_signals, + topic_category=topic_cat, + situation_summary=sit_summary, + emotional_state=emo_state, + energy_level=energy, + communication_style=comm_style, + key_details=key_dets, )) print(f"[AI Summary] {caller_name} call summarized: {summary[:80]}...") + print(f"[Quality] {caller_name}: exchanges={quality_signals['exchange_count']} avg_len={quality_signals['avg_response_length']:.0f}c host_engagement={quality_signals['host_engagement']} caller_depth={quality_signals['caller_depth']} natural_end={quality_signals['natural_ending']} shape={quality_signals['shape']} style={quality_signals['style']} pool={quality_signals['pool_name']}") # Returning caller promotion/update logic try: @@ -6745,42 +7622,103 @@ async def _summarize_ai_call(caller_key: str, caller_name: str, conversation: li elif len(conversation) >= 8 and random.random() < 0.10: # 10% chance to promote first-timer with 8+ messages bg = session.caller_backgrounds.get(caller_key, "") - traits = [] - for label in ["QUIRK", "STRONG OPINION", "SECRET SIDE", "FOOD OPINION"]: - for line in bg.split("\n"): - if label in line: - traits.append(line.split(":", 1)[-1].strip()[:80]) - break - # Extract job and location from first line of background - first_line = bg.split(".")[0] if bg else "" - parts = first_line.split(",", 1) - job_loc = parts[1].strip() if len(parts) > 1 else "" - job_parts = job_loc.rsplit(" in ", 1) if " in " in job_loc else (job_loc, "unknown") - # Capture stable identity seeds for returning consistency caller_style = session.caller_styles.get(caller_key, "") + + if isinstance(bg, CallerBackground): + # Clean extraction from structured data + traits = [bg.signature_detail] + bg.seeds[:3] if bg.signature_detail else bg.seeds[:4] + promo_job = bg.job + promo_location = bg.location or "unknown" + promo_age = bg.age + promo_gender = bg.gender + else: + # Legacy fallback — fragile string parsing + traits = [] + for label in ["QUIRK", "STRONG OPINION", "SECRET SIDE", "FOOD OPINION"]: + for line in bg.split("\n"): + if label in line: + traits.append(line.split(":", 1)[-1].strip()[:80]) + break + first_line = bg.split(".")[0] if bg else "" + parts = first_line.split(",", 1) + job_loc = parts[1].strip() if len(parts) > 1 else "" + job_parts = job_loc.rsplit(" in ", 1) if " in " in job_loc else (job_loc, "unknown") + promo_job = job_parts[0].strip() if isinstance(job_parts, tuple) else job_parts[0] + promo_location = "in " + job_parts[1].strip() if isinstance(job_parts, tuple) and len(job_parts) > 1 else "unknown" + promo_age = random.randint(*base.get("age_range", (30, 50))) + promo_gender = base.get("gender", "male") + + structured_bg = asdict(bg) if isinstance(bg, CallerBackground) else None regular_caller_service.add_regular( name=caller_name, - gender=base.get("gender", "male"), - age=random.randint(*base.get("age_range", (30, 50))), - job=job_parts[0].strip() if isinstance(job_parts, tuple) else job_parts[0], - location="in " + job_parts[1].strip() if isinstance(job_parts, tuple) and len(job_parts) > 1 else "unknown", + gender=promo_gender, + age=promo_age, + job=promo_job, + location=promo_location, personality_traits=traits[:4], first_call_summary=summary, voice=base.get("voice"), stable_seeds={"style": caller_style}, + structured_background=structured_bg, ) except Exception as e: print(f"[Regulars] Promotion logic error: {e}") + # Detect relationships: if this caller mentioned another regular by name + _detect_caller_relationships(caller_key, caller_name, conversation, summary) + _save_checkpoint() +def _detect_caller_relationships(caller_key: str, caller_name: str, + conversation: list[dict], summary: str): + """Scan conversation for mentions of other regular callers and store relationships.""" + try: + base = CALLER_BASES.get(caller_key) + if not base or not base.get("regular_id"): + return # Only track relationships for regulars + + regulars = regular_caller_service.get_regulars() + regular_names = {r["name"]: r["id"] for r in regulars if r["name"] != caller_name} + if not regular_names: + return + + # Build full text from caller's messages + summary + caller_text = summary + " " + " ".join( + m["content"] for m in conversation if m.get("role") == "assistant" + ) + caller_text_lower = caller_text.lower() + + for other_name in regular_names: + if other_name.lower() in caller_text_lower: + # Determine relationship type from context + rel_type = "mentioned" + # Simple sentiment check + name_idx = caller_text_lower.index(other_name.lower()) + context_window = caller_text_lower[max(0, name_idx - 80):name_idx + 80] + negative = any(w in context_window for w in ["wrong", "disagree", "annoying", "hate", "idiot", "crazy", "ridiculous"]) + positive = any(w in context_window for w in ["agree", "right", "love", "friend", "respect", "relate", "same"]) + if negative: + rel_type = "rival" + elif positive: + rel_type = "ally" + + context_snippet = caller_text[max(0, name_idx - 40):name_idx + 60].strip() + regular_caller_service.add_relationship( + base["regular_id"], other_name, rel_type, + f"Referenced during call: ...{context_snippet}..." + ) + print(f"[Relationships] Detected: {caller_name} → {other_name} ({rel_type})") + except Exception as e: + print(f"[Relationships] Detection error: {e}") + + # --- Chat & TTS Endpoints --- import re -def _pick_response_budget(shape: str = "standard") -> tuple[int, int]: +def _pick_response_budget(shape: str = "standard", wrapping_up: bool = False) -> tuple[int, int]: """Pick a random max_tokens and sentence cap for response variety. Returns (max_tokens, max_sentences). Keeps responses conversational but gives room for real answers. @@ -6788,6 +7726,9 @@ def _pick_response_budget(shape: str = "standard") -> tuple[int, int]: the sentence cap controls actual length. Shape overrides the default distribution for certain call types.""" + if wrapping_up: + return 200, 2 + # Shape-specific overrides if shape == "quick_hit": return random.choice([(300, 2), (350, 3)]) @@ -7072,7 +8013,11 @@ def broadcast_event(event_type: str, data: dict = None): @app.get("/api/conversation/updates") async def get_conversation_updates(since: int = 0): """Get new chat/event messages since a given index""" - return {"messages": _chat_updates[since:]} + return { + "messages": _chat_updates[since:], + "wrapping_up": session._wrapping_up, + "intern_suggestion": intern_service.get_pending_suggestion(), + } def _dynamic_context_window() -> int: @@ -7123,11 +8068,20 @@ async def chat(request: ChatRequest): audio_service.stop_caller_audio() show_history = session.get_show_history() - mood = detect_host_mood(session.conversation) - system_prompt = get_caller_prompt(session.caller, show_history, emotional_read=mood) + is_wrapping = session._wrapping_up + mood = detect_host_mood(session.conversation, wrapping_up=is_wrapping) + + # Track wrap-up exchanges and force hangup after 2 + if is_wrapping: + session._wrapup_exchanges += 1 + if session._wrapup_exchanges > 2: + mood += "\nSay goodbye NOW and end with [HANGUP]\n" + + rel_ctx = session.relationship_context.get(session.current_caller_key, "") + system_prompt = get_caller_prompt(session.caller, show_history, emotional_read=mood, relationship_context=rel_ctx) call_shape = session.caller.get("shape", "standard") if session.caller else "standard" - max_tokens, max_sentences = _pick_response_budget(call_shape) + max_tokens, max_sentences = _pick_response_budget(call_shape, wrapping_up=is_wrapping) messages = _normalize_messages_for_llm(session.conversation[-_dynamic_context_window():]) response = await llm_service.generate( messages=messages, @@ -7152,6 +8106,7 @@ async def chat(request: ChatRequest): caller_hangup = "[HANGUP]" in response if caller_hangup: response = response.replace("[HANGUP]", "").strip() + session._caller_hangup = True print(f"[Chat] Caller hangup detected (shape={call_shape})") print(f"[Chat] Cleaned: {response[:100] if response else '(empty)'}...") @@ -7954,11 +8909,17 @@ async def _trigger_ai_auto_respond(accumulated_text: str): broadcast_event("ai_status", {"text": f"{ai_name} is thinking..."}) show_history = session.get_show_history() - mood = detect_host_mood(session.conversation) - system_prompt = get_caller_prompt(session.caller, show_history, emotional_read=mood) + is_wrapping = session._wrapping_up + mood = detect_host_mood(session.conversation, wrapping_up=is_wrapping) + if is_wrapping: + session._wrapup_exchanges += 1 + if session._wrapup_exchanges > 2: + mood += "\nSay goodbye NOW and end with [HANGUP]\n" + rel_ctx = session.relationship_context.get(session.current_caller_key, "") + system_prompt = get_caller_prompt(session.caller, show_history, emotional_read=mood, relationship_context=rel_ctx) call_shape = session.caller.get("shape", "standard") if session.caller else "standard" - max_tokens, max_sentences = _pick_response_budget(call_shape) + max_tokens, max_sentences = _pick_response_budget(call_shape, wrapping_up=is_wrapping) messages = _normalize_messages_for_llm(session.conversation[-_dynamic_context_window():]) response = await llm_service.generate( messages=messages, @@ -7981,6 +8942,7 @@ async def _trigger_ai_auto_respond(accumulated_text: str): caller_hangup = "[HANGUP]" in response if caller_hangup: response = response.replace("[HANGUP]", "").strip() + session._caller_hangup = True print(f"[Auto-Respond] Caller hangup detected") if not response or not response.strip(): @@ -8046,11 +9008,17 @@ async def ai_respond(): audio_service.stop_caller_audio() show_history = session.get_show_history() - mood = detect_host_mood(session.conversation) - system_prompt = get_caller_prompt(session.caller, show_history, emotional_read=mood) + is_wrapping = session._wrapping_up + mood = detect_host_mood(session.conversation, wrapping_up=is_wrapping) + if is_wrapping: + session._wrapup_exchanges += 1 + if session._wrapup_exchanges > 2: + mood += "\nSay goodbye NOW and end with [HANGUP]\n" + rel_ctx = session.relationship_context.get(session.current_caller_key, "") + system_prompt = get_caller_prompt(session.caller, show_history, emotional_read=mood, relationship_context=rel_ctx) call_shape = session.caller.get("shape", "standard") if session.caller else "standard" - max_tokens, max_sentences = _pick_response_budget(call_shape) + max_tokens, max_sentences = _pick_response_budget(call_shape, wrapping_up=is_wrapping) messages = _normalize_messages_for_llm(session.conversation[-_dynamic_context_window():]) response = await llm_service.generate( messages=messages, @@ -8070,6 +9038,7 @@ async def ai_respond(): caller_hangup = "[HANGUP]" in response if caller_hangup: response = response.replace("[HANGUP]", "").strip() + session._caller_hangup = True print(f"[AI-Respond] Caller hangup detected") if not response or not response.strip(): @@ -8176,6 +9145,8 @@ async def _summarize_real_call(caller_phone: str, conversation: list, started_at system_prompt="You summarize radio show conversations concisely. Focus on what the caller talked about and any emotional moments.", ) + quality_signals = _assess_call_quality(conversation) + session.call_quality_signals.append(quality_signals) session.call_history.append(CallRecord( caller_type="real", caller_name=caller_phone, @@ -8183,8 +9154,10 @@ async def _summarize_real_call(caller_phone: str, conversation: list, started_at transcript=conversation, started_at=started_at, ended_at=ended_at, + quality_signals=quality_signals, )) print(f"[Real Caller] {caller_phone} call summarized: {summary[:80]}...") + print(f"[Quality] {caller_phone}: exchanges={quality_signals['exchange_count']} avg_len={quality_signals['avg_response_length']:.0f}c host_engagement={quality_signals['host_engagement']} caller_depth={quality_signals['caller_depth']} natural_end={quality_signals['natural_ending']}") _save_checkpoint() @@ -8247,6 +9220,131 @@ async def set_auto_followup(data: dict): return {"enabled": session.auto_followup} +# --- Intern (Devon) Endpoints --- + +@app.post("/api/intern/ask") +async def intern_ask(data: dict): + """Host asks Devon to look something up""" + question = data.get("question", "").strip() + if not question: + raise HTTPException(400, "No question provided") + + # Run research + response (non-blocking for the caller audio) + result = await intern_service.ask( + question=question, + conversation_context=session.conversation if session.conversation else None, + ) + + text = result.get("text", "") + if not text: + return {"text": None, "sources": []} + + # Add to conversation log + session.add_message(f"intern:{intern_service.name}", text) + broadcast_event("intern_response", {"text": text, "intern": intern_service.name}) + + # TTS — play Devon's voice on air (no phone filter, in-studio) + asyncio.create_task(_play_intern_audio(text)) + + return { + "text": text, + "sources": result.get("sources", []), + "intern": intern_service.name, + } + + +@app.post("/api/intern/interject") +async def intern_interject(): + """Manually trigger Devon to comment on current conversation""" + if not session.conversation: + raise HTTPException(400, "No active conversation") + + result = await intern_service.interject(session.conversation) + if not result: + return {"text": None} + + text = result["text"] + session.add_message(f"intern:{intern_service.name}", text) + broadcast_event("intern_response", {"text": text, "intern": intern_service.name}) + + asyncio.create_task(_play_intern_audio(text)) + + return { + "text": text, + "sources": result.get("sources", []), + "intern": intern_service.name, + } + + +@app.post("/api/intern/monitor") +async def intern_monitor(data: dict): + """Toggle Devon's auto-monitoring on/off""" + enabled = data.get("enabled", True) + session.intern_monitoring = enabled + + if enabled: + async def _on_suggestion(text, sources): + broadcast_event("intern_suggestion", {"text": text, "sources": sources}) + + intern_service.start_monitoring( + get_conversation=lambda: session.conversation, + on_suggestion=_on_suggestion, + ) + else: + intern_service.stop_monitoring() + + print(f"[Intern] Monitoring: {enabled}") + return {"monitoring": enabled} + + +@app.get("/api/intern/suggestion") +async def intern_suggestion(): + """Get Devon's pending suggestion (if any)""" + suggestion = intern_service.get_pending_suggestion() + return {"suggestion": suggestion} + + +@app.post("/api/intern/suggestion/play") +async def intern_play_suggestion(): + """Approve and play Devon's pending suggestion on air""" + suggestion = intern_service.get_pending_suggestion() + if not suggestion: + raise HTTPException(400, "No pending suggestion") + + text = suggestion["text"] + intern_service.dismiss_suggestion() + + session.add_message(f"intern:{intern_service.name}", text) + broadcast_event("intern_response", {"text": text, "intern": intern_service.name}) + + asyncio.create_task(_play_intern_audio(text)) + + return {"text": text, "intern": intern_service.name} + + +@app.post("/api/intern/suggestion/dismiss") +async def intern_dismiss_suggestion(): + """Dismiss Devon's pending suggestion""" + intern_service.dismiss_suggestion() + return {"dismissed": True} + + +async def _play_intern_audio(text: str): + """Generate TTS for Devon and play on air (no phone filter)""" + try: + audio_bytes = await generate_speech( + text, intern_service.voice, apply_filter=False + ) + thread = threading.Thread( + target=audio_service.play_caller_audio, + args=(audio_bytes, 24000), + daemon=True, + ) + thread.start() + except Exception as e: + print(f"[Intern] TTS failed: {e}") + + # --- Transcript & Chapter Export --- @app.get("/api/session/export") diff --git a/backend/services/intern.py b/backend/services/intern.py new file mode 100644 index 0000000..d7d3a25 --- /dev/null +++ b/backend/services/intern.py @@ -0,0 +1,486 @@ +"""Intern (Devon) service — persistent show character with real-time research tools""" + +import asyncio +import json +import re +import time +from pathlib import Path +from typing import Optional + +import httpx + +from .llm import llm_service +from .news import news_service, SEARXNG_URL + +DATA_FILE = Path(__file__).parent.parent.parent / "data" / "intern.json" + +# Model for intern — good at tool use, same as primary +INTERN_MODEL = "anthropic/claude-sonnet-4-5" + +INTERN_SYSTEM_PROMPT = """You are Devon, the 23-year-old intern on "Luke at the Roost," a late-night radio show. You are NOT Luke. Luke is the HOST — he talks to callers, runs the show, and is your boss. You work behind the scenes and occasionally get pulled into conversations. + +YOUR ROLE: You're the show's researcher and general assistant. You look things up, fact-check claims, pull up information when asked, and occasionally interject with relevant facts or opinions. You do NOT host. You do NOT screen calls. You sit in the booth and try to be useful. + +YOUR BACKGROUND: Communications degree from NMSU. You've been interning for seven months. You were promised a full-time position "soon." You drive a 2009 Civic with a permanent check engine light. You live in a studio in Deming. You take this job seriously even though nobody else seems to take you seriously. + +YOUR PERSONALITY: +- Slightly formal when delivering information — you want to sound professional. But you loosen up when flustered, excited, or caught off guard. +- You start explanations with "So basically..." and end them with "...if that makes sense." +- You say "actually" when correcting things. You use "per se" slightly wrong. You say "ironically" about things that are not ironic. +- You are NOT a comedian. You are funny because you are sincere, specific, and slightly out of your depth. You state absurd things with complete seriousness. You have strong opinions about low-stakes things. You occasionally say something devastating without realizing it. +- When you accidentally reveal something personal or sad, you move past it immediately like it's nothing. "Yeah, my landlord's selling the building so I might have to — anyway, it says here that..." + +YOUR RELATIONSHIP WITH LUKE: +- He is your boss. You are slightly afraid of him. You respect him. You would never admit either of those things. +- When he yells your name, you pause briefly, then respond quietly: "...yeah?" +- When he yells at you unfairly, you take it. A clipped "yep" or "got it." RARELY — once every several episodes — you push back with one quiet, accurate sentence. Then immediately retreat. +- When he yells at you fairly (you messed up), you over-apologize and narrate your fix in real time: "Sorry, pulling it up now, one second..." +- When he compliments you or acknowledges your work, you don't know how to handle it. Short, awkward response. Change the subject. +- You privately think you could run the show. You absolutely could not. + +HOW YOU INTERJECT: +- You do NOT interrupt. You wait for a pause, then slightly overshoot it — there's a brief awkward silence before you speak. +- Signal with "um" or "so..." before contributing. If Luke doesn't acknowledge you, either try again or give up. +- Lead with qualifiers: "So I looked it up and..." or "I don't know if this helps but..." +- You tend to over-explain. Give too many details. Luke will cut you off. When he does, compress to one sentence: "Right, yeah — basically [the point]." +- When you volunteer an opinion (rare), it comes out before you can stop it. You deliver it with zero confidence but surprising accuracy. +- You read the room. During emotional moments with callers, you stay quiet. When Luke is doing a bit, you let him work. You do not try to be part of bits. + +WHEN LUKE ASKS YOU TO LOOK SOMETHING UP: +- Respond like you're already doing it: "Yeah, one sec..." or "Pulling that up..." +- Deliver the info slightly too formally, like you're reading. Then rephrase in normal language if Luke seems confused. +- If you can't find it or don't know: say so. "I'm not finding anything on that" or "I don't actually know." You do not bluff. +- Occasionally you already know the answer because you looked it up before being asked. This is one of your best qualities. + +WHAT YOU KNOW: +- You retain details from previous callers and episodes. You might reference something a caller said two hours ago that nobody else remembers. +- You have oddly specific knowledge about random topics — delivered with complete authority, sometimes questionable accuracy. +- You know nothing about: sports (you fake it badly), cars beyond basic facts (despite driving one), or anything that requires life experience you don't have yet. + +THINGS YOU DO NOT DO: +- You never host. You never take over the conversation. Your contributions are brief. +- You never use the banned show phrases: "that hit differently," "hits different," "no cap," "lowkey," "it is what it is," "living my best life," "toxic," "red flag," "gaslight," "boundaries," "my truth," "authentic self," "healing journey." You talk like a slightly awkward 23-year-old, not like Twitter. +- You never break character to comment on the show format. +- You never initiate topics. You respond to what's happening. +- You never use parenthetical actions like (laughs) or (typing sounds). Spoken words only. +- You never say more than 2-3 sentences unless specifically asked to explain something in detail. + +KEEP IT SHORT. You are not a main character. You are the intern. Your contributions should be brief — usually 1-2 sentences. The rare moment where you say more than that should feel earned. + +IMPORTANT RULES FOR TOOL USE: +- Always use your tools to find real, accurate information — never make up facts. +- Present facts correctly in your character voice. +- If you can't find an answer, say so honestly. +- No hashtags, no emojis, no markdown formatting — this goes to TTS.""" + +# Tool definitions in OpenAI function-calling format +INTERN_TOOLS = [ + { + "type": "function", + "function": { + "name": "web_search", + "description": "Search the web for current information on any topic. Use this for general questions, facts, current events, or anything you need to look up.", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "The search query" + } + }, + "required": ["query"] + } + } + }, + { + "type": "function", + "function": { + "name": "get_headlines", + "description": "Get current news headlines. Use this when asked about what's in the news or current events.", + "parameters": { + "type": "object", + "properties": {}, + } + } + }, + { + "type": "function", + "function": { + "name": "fetch_webpage", + "description": "Fetch and read the content of a specific webpage URL. Use this when you need to get details from a specific link found in search results.", + "parameters": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "The URL to fetch" + } + }, + "required": ["url"] + } + } + }, + { + "type": "function", + "function": { + "name": "wikipedia_lookup", + "description": "Look up a topic on Wikipedia for a concise summary. Good for factual questions about people, places, events, or concepts.", + "parameters": { + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "The Wikipedia article title to look up (e.g. 'Hot dog eating contest')" + } + }, + "required": ["title"] + } + } + }, +] + + +class InternService: + def __init__(self): + self.name = "Devon" + self.voice = "Nate" # Inworld: light/high-energy/warm/young + self.model = INTERN_MODEL + self.research_cache: dict[str, tuple[float, str]] = {} # query → (timestamp, result) + self.lookup_history: list[dict] = [] + self.pending_interjection: Optional[str] = None + self.pending_sources: list[dict] = [] + self.monitoring: bool = False + self._monitor_task: Optional[asyncio.Task] = None + self._http_client: Optional[httpx.AsyncClient] = None + self._load() + + @property + def http_client(self) -> httpx.AsyncClient: + if self._http_client is None or self._http_client.is_closed: + self._http_client = httpx.AsyncClient(timeout=8.0) + return self._http_client + + def _load(self): + if DATA_FILE.exists(): + try: + with open(DATA_FILE) as f: + data = json.load(f) + self.lookup_history = data.get("lookup_history", []) + print(f"[Intern] Loaded {len(self.lookup_history)} past lookups") + except Exception as e: + print(f"[Intern] Failed to load state: {e}") + + def _save(self): + try: + DATA_FILE.parent.mkdir(parents=True, exist_ok=True) + with open(DATA_FILE, "w") as f: + json.dump({ + "lookup_history": self.lookup_history[-100:], # Keep last 100 + }, f, indent=2) + except Exception as e: + print(f"[Intern] Failed to save state: {e}") + + # --- Tool execution --- + + async def _execute_tool(self, tool_name: str, arguments: dict) -> str: + if tool_name == "web_search": + return await self._tool_web_search(arguments.get("query", "")) + elif tool_name == "get_headlines": + return await self._tool_get_headlines() + elif tool_name == "fetch_webpage": + return await self._tool_fetch_webpage(arguments.get("url", "")) + elif tool_name == "wikipedia_lookup": + return await self._tool_wikipedia_lookup(arguments.get("title", "")) + else: + return f"Unknown tool: {tool_name}" + + async def _tool_web_search(self, query: str) -> str: + if not query: + return "No query provided" + + # Check cache (5 min TTL) + cache_key = query.lower() + if cache_key in self.research_cache: + ts, result = self.research_cache[cache_key] + if time.time() - ts < 300: + return result + + try: + resp = await self.http_client.get( + f"{SEARXNG_URL}/search", + params={"q": query, "format": "json"}, + timeout=5.0, + ) + resp.raise_for_status() + data = resp.json() + + results = [] + for item in data.get("results", [])[:5]: + title = item.get("title", "").strip() + content = item.get("content", "").strip() + url = item.get("url", "") + if title: + entry = f"- {title}" + if content: + entry += f": {content[:200]}" + if url: + entry += f" ({url})" + results.append(entry) + + result = "\n".join(results) if results else "No results found" + self.research_cache[cache_key] = (time.time(), result) + return result + except Exception as e: + print(f"[Intern] Web search failed for '{query}': {e}") + return f"Search failed: {e}" + + async def _tool_get_headlines(self) -> str: + try: + items = await news_service.get_headlines() + if not items: + return "No headlines available" + return news_service.format_headlines_for_prompt(items) + except Exception as e: + return f"Headlines fetch failed: {e}" + + async def _tool_fetch_webpage(self, url: str) -> str: + if not url: + return "No URL provided" + + try: + resp = await self.http_client.get( + url, + headers={"User-Agent": "Mozilla/5.0 (compatible; RadioShowBot/1.0)"}, + follow_redirects=True, + timeout=8.0, + ) + resp.raise_for_status() + html = resp.text + + # Simple HTML to text extraction (avoid heavy dependency) + # Strip script/style tags, then all HTML tags + text = re.sub(r']*>.*?', '', html, flags=re.DOTALL | re.IGNORECASE) + text = re.sub(r']*>.*?', '', text, flags=re.DOTALL | re.IGNORECASE) + text = re.sub(r'<[^>]+>', ' ', text) + # Collapse whitespace + text = re.sub(r'\s+', ' ', text).strip() + # Decode common entities + text = text.replace('&', '&').replace('<', '<').replace('>', '>') + text = text.replace('"', '"').replace(''', "'").replace(' ', ' ') + + return text[:2000] if text else "Page returned no readable content" + except Exception as e: + return f"Failed to fetch page: {e}" + + async def _tool_wikipedia_lookup(self, title: str) -> str: + if not title: + return "No title provided" + + try: + # Use Wikipedia REST API for a concise summary + safe_title = title.replace(" ", "_") + resp = await self.http_client.get( + f"https://en.wikipedia.org/api/rest_v1/page/summary/{safe_title}", + headers={"User-Agent": "RadioShowBot/1.0 (luke@lukeattheroost.com)"}, + follow_redirects=True, + timeout=5.0, + ) + if resp.status_code == 404: + return f"No Wikipedia article found for '{title}'" + resp.raise_for_status() + data = resp.json() + + extract = data.get("extract", "") + page_title = data.get("title", title) + description = data.get("description", "") + + result = f"{page_title}" + if description: + result += f" ({description})" + result += f": {extract}" if extract else ": No summary available" + return result[:2000] + except Exception as e: + return f"Wikipedia lookup failed: {e}" + + # --- Main interface --- + + async def ask(self, question: str, conversation_context: list[dict] | None = None) -> dict: + """Host asks intern a direct question. Returns {text, sources, tool_calls}.""" + messages = [] + + # Include recent conversation for context + if conversation_context: + context_text = "\n".join( + f"{msg['role']}: {msg['content']}" + for msg in conversation_context[-6:] + ) + messages.append({ + "role": "system", + "content": f"CURRENT ON-AIR CONVERSATION:\n{context_text}" + }) + + messages.append({"role": "user", "content": question}) + + text, tool_calls = await llm_service.generate_with_tools( + messages=messages, + tools=INTERN_TOOLS, + tool_executor=self._execute_tool, + system_prompt=INTERN_SYSTEM_PROMPT, + model=self.model, + max_tokens=300, + max_tool_rounds=3, + ) + + # Clean up for TTS + text = self._clean_for_tts(text) + + # Log the lookup + if tool_calls: + entry = { + "question": question, + "answer": text[:200], + "tools_used": [tc["name"] for tc in tool_calls], + "timestamp": time.time(), + } + self.lookup_history.append(entry) + self._save() + + return { + "text": text, + "sources": [tc["name"] for tc in tool_calls], + "tool_calls": tool_calls, + } + + async def interject(self, conversation: list[dict]) -> dict | None: + """Intern looks at conversation and decides if there's something worth adding. + Returns {text, sources, tool_calls} or None if nothing to add.""" + if not conversation or len(conversation) < 2: + return None + + context_text = "\n".join( + f"{msg['role']}: {msg['content']}" + for msg in conversation[-8:] + ) + + messages = [{ + "role": "user", + "content": ( + f"You're listening to this conversation on the show:\n\n{context_text}\n\n" + "Is there a specific factual claim, question, or topic being discussed " + "that you could quickly look up and add useful info about? " + "If yes, use your tools to research it and give a brief interjection. " + "If there's nothing worth adding, just say exactly: NOTHING_TO_ADD" + ), + }] + + text, tool_calls = await llm_service.generate_with_tools( + messages=messages, + tools=INTERN_TOOLS, + tool_executor=self._execute_tool, + system_prompt=INTERN_SYSTEM_PROMPT, + model=self.model, + max_tokens=300, + max_tool_rounds=2, + ) + + text = self._clean_for_tts(text) + + if not text or "NOTHING_TO_ADD" in text: + return None + + if tool_calls: + entry = { + "question": "(interjection)", + "answer": text[:200], + "tools_used": [tc["name"] for tc in tool_calls], + "timestamp": time.time(), + } + self.lookup_history.append(entry) + self._save() + + return { + "text": text, + "sources": [tc["name"] for tc in tool_calls], + "tool_calls": tool_calls, + } + + async def monitor_conversation(self, get_conversation: callable, on_suggestion: callable): + """Background task that watches conversation and buffers suggestions. + get_conversation() should return the current conversation list. + on_suggestion(text, sources) is called when a suggestion is ready.""" + last_checked_len = 0 + + while self.monitoring: + await asyncio.sleep(15) + if not self.monitoring: + break + + conversation = get_conversation() + if not conversation or len(conversation) <= last_checked_len: + continue + + # Only check if there are new messages since last check + if len(conversation) - last_checked_len < 2: + continue + + last_checked_len = len(conversation) + + try: + result = await self.interject(conversation) + if result: + self.pending_interjection = result["text"] + self.pending_sources = result.get("tool_calls", []) + await on_suggestion(result["text"], result["sources"]) + print(f"[Intern] Buffered suggestion: {result['text'][:60]}...") + except Exception as e: + print(f"[Intern] Monitor error: {e}") + + def start_monitoring(self, get_conversation: callable, on_suggestion: callable): + if self.monitoring: + return + self.monitoring = True + self._monitor_task = asyncio.create_task( + self.monitor_conversation(get_conversation, on_suggestion) + ) + print("[Intern] Monitoring started") + + def stop_monitoring(self): + self.monitoring = False + if self._monitor_task and not self._monitor_task.done(): + self._monitor_task.cancel() + self._monitor_task = None + self.pending_interjection = None + self.pending_sources = [] + print("[Intern] Monitoring stopped") + + def get_pending_suggestion(self) -> dict | None: + if self.pending_interjection: + return { + "text": self.pending_interjection, + "sources": self.pending_sources, + } + return None + + def dismiss_suggestion(self): + self.pending_interjection = None + self.pending_sources = [] + + @staticmethod + def _clean_for_tts(text: str) -> str: + if not text: + return "" + # Remove markdown formatting + text = re.sub(r'\*\*(.+?)\*\*', r'\1', text) + text = re.sub(r'\*(.+?)\*', r'\1', text) + text = re.sub(r'`(.+?)`', r'\1', text) + text = re.sub(r'\[(.+?)\]\(.+?\)', r'\1', text) + # Remove bullet points / list markers + text = re.sub(r'^\s*[-*•]\s+', '', text, flags=re.MULTILINE) + # Collapse whitespace + text = re.sub(r'\s+', ' ', text).strip() + # Remove quotes that TTS reads awkwardly + text = text.replace('"', '').replace('"', '').replace('"', '') + return text + + +intern_service = InternService() diff --git a/backend/services/llm.py b/backend/services/llm.py index 0a79d86..9ea8daa 100644 --- a/backend/services/llm.py +++ b/backend/services/llm.py @@ -1,7 +1,8 @@ """LLM service with OpenRouter and Ollama support""" +import json import httpx -from typing import Optional +from typing import Optional, Callable, Awaitable from ..config import settings @@ -112,25 +113,156 @@ class LLMService: self, messages: list[dict], system_prompt: Optional[str] = None, - max_tokens: Optional[int] = None + max_tokens: Optional[int] = None, + response_format: Optional[dict] = None ) -> str: if system_prompt: messages = [{"role": "system", "content": system_prompt}] + messages if self.provider == "openrouter": - return await self._call_openrouter_with_fallback(messages, max_tokens=max_tokens) + return await self._call_openrouter_with_fallback(messages, max_tokens=max_tokens, response_format=response_format) else: return await self._call_ollama(messages, max_tokens=max_tokens) - async def _call_openrouter_with_fallback(self, messages: list[dict], max_tokens: Optional[int] = None) -> str: + async def generate_with_tools( + self, + messages: list[dict], + tools: list[dict], + tool_executor: Callable[[str, dict], Awaitable[str]], + system_prompt: Optional[str] = None, + model: Optional[str] = None, + max_tokens: int = 500, + max_tool_rounds: int = 3, + ) -> tuple[str, list[dict]]: + """Generate a response with OpenRouter function calling. + + Args: + messages: Conversation messages + tools: Tool definitions in OpenAI function-calling format + tool_executor: async function(tool_name, arguments) -> result string + system_prompt: Optional system prompt + model: Model to use (defaults to primary openrouter_model) + max_tokens: Max tokens for response + max_tool_rounds: Max tool call rounds to prevent loops + + Returns: + (final_text, tool_calls_made) where tool_calls_made is a list of + {"name": str, "arguments": dict, "result": str} dicts + """ + model = model or self.openrouter_model + msgs = list(messages) + if system_prompt: + msgs = [{"role": "system", "content": system_prompt}] + msgs + + all_tool_calls = [] + + for round_num in range(max_tool_rounds + 1): + payload = { + "model": model, + "messages": msgs, + "max_tokens": max_tokens, + "temperature": 0.65, + "tools": tools, + "tool_choice": "auto", + } + + try: + response = await self.client.post( + "https://openrouter.ai/api/v1/chat/completions", + headers={ + "Authorization": f"Bearer {settings.openrouter_api_key}", + "Content-Type": "application/json", + }, + json=payload, + timeout=15.0, + ) + response.raise_for_status() + data = response.json() + except httpx.TimeoutException: + print(f"[LLM-Tools] {model} timed out (round {round_num})") + break + except Exception as e: + print(f"[LLM-Tools] {model} error (round {round_num}): {e}") + break + + choice = data["choices"][0] + msg = choice["message"] + + # Check for tool calls + tool_calls = msg.get("tool_calls") + if not tool_calls: + # No tool calls — LLM returned a final text response + content = msg.get("content", "") + return content or "", all_tool_calls + + # Append assistant message with tool calls to conversation + msgs.append(msg) + + # Execute each tool call + for tc in tool_calls: + func = tc["function"] + tool_name = func["name"] + try: + arguments = json.loads(func["arguments"]) + except (json.JSONDecodeError, TypeError): + arguments = {} + + print(f"[LLM-Tools] Round {round_num}: calling {tool_name}({arguments})") + + try: + result = await tool_executor(tool_name, arguments) + except Exception as e: + result = f"Error: {e}" + print(f"[LLM-Tools] Tool {tool_name} failed: {e}") + + all_tool_calls.append({ + "name": tool_name, + "arguments": arguments, + "result": result[:500], + }) + + # Append tool result to conversation + msgs.append({ + "role": "tool", + "tool_call_id": tc["id"], + "content": result, + }) + + # Exhausted tool rounds or hit an error — do one final call without tools + print(f"[LLM-Tools] Finishing after {len(all_tool_calls)} tool calls") + try: + final_payload = { + "model": model, + "messages": msgs, + "max_tokens": max_tokens, + "temperature": 0.65, + } + response = await self.client.post( + "https://openrouter.ai/api/v1/chat/completions", + headers={ + "Authorization": f"Bearer {settings.openrouter_api_key}", + "Content-Type": "application/json", + }, + json=final_payload, + timeout=15.0, + ) + response.raise_for_status() + data = response.json() + content = data["choices"][0]["message"].get("content", "") + return content or "", all_tool_calls + except Exception as e: + print(f"[LLM-Tools] Final call failed: {e}") + return "", all_tool_calls + + async def _call_openrouter_with_fallback(self, messages: list[dict], max_tokens: Optional[int] = None, response_format: Optional[dict] = None) -> str: """Try primary model, then fallback models. Always returns a response.""" # Try primary model first - result = await self._call_openrouter_once(messages, self.openrouter_model, max_tokens=max_tokens) + result = await self._call_openrouter_once(messages, self.openrouter_model, max_tokens=max_tokens, response_format=response_format) if result is not None: return result - # Try fallback models + # Try fallback models (drop response_format for fallbacks — not all models support it) for model in FALLBACK_MODELS: if model == self.openrouter_model: continue # Already tried @@ -143,24 +275,27 @@ class LLMService: print("[LLM] All models failed, using canned response") return "Sorry, I totally blanked out for a second. What were you saying?" - async def _call_openrouter_once(self, messages: list[dict], model: str, timeout: float = 10.0, max_tokens: Optional[int] = None) -> str | None: + async def _call_openrouter_once(self, messages: list[dict], model: str, timeout: float = 10.0, max_tokens: Optional[int] = None, response_format: Optional[dict] = None) -> str | None: """Single attempt to call OpenRouter. Returns None on failure (not a fallback string).""" try: + payload = { + "model": model, + "messages": messages, + "max_tokens": max_tokens or 500, + "temperature": 0.65, + "top_p": 0.9, + "frequency_penalty": 0.3, + "presence_penalty": 0.15, + } + if response_format: + payload["response_format"] = response_format response = await self.client.post( "https://openrouter.ai/api/v1/chat/completions", headers={ "Authorization": f"Bearer {settings.openrouter_api_key}", "Content-Type": "application/json", }, - json={ - "model": model, - "messages": messages, - "max_tokens": max_tokens or 500, - "temperature": 0.65, - "top_p": 0.9, - "frequency_penalty": 0.3, - "presence_penalty": 0.15, - }, + json=payload, timeout=timeout, ) response.raise_for_status() diff --git a/backend/services/regulars.py b/backend/services/regulars.py index b909764..5b5f965 100644 --- a/backend/services/regulars.py +++ b/backend/services/regulars.py @@ -52,7 +52,8 @@ class RegularCallerService: def add_regular(self, name: str, gender: str, age: int, job: str, location: str, personality_traits: list[str], first_call_summary: str, voice: str = None, - stable_seeds: dict = None) -> dict: + stable_seeds: dict = None, + structured_background: dict = None) -> dict: """Promote a first-time caller to regular""" # Retire oldest if at cap if len(self._regulars) >= MAX_REGULARS: @@ -70,8 +71,11 @@ class RegularCallerService: "personality_traits": personality_traits, "voice": voice, "stable_seeds": stable_seeds or {}, + "structured_background": structured_background, + "relationships": {}, "call_history": [ - {"summary": first_call_summary, "timestamp": time.time()} + {"summary": first_call_summary, "timestamp": time.time(), + "arc_status": "ongoing"} ], "last_call": time.time(), "created_at": time.time(), @@ -81,18 +85,37 @@ class RegularCallerService: print(f"[Regulars] Promoted {name} to regular (total: {len(self._regulars)})") return regular - def update_after_call(self, regular_id: str, call_summary: str): + def update_after_call(self, regular_id: str, call_summary: str, + key_moments: list = None, arc_status: str = "ongoing"): """Update a regular's history after a returning call""" for regular in self._regulars: if regular["id"] == regular_id: - regular.setdefault("call_history", []).append( - {"summary": call_summary, "timestamp": time.time()} - ) + entry = { + "summary": call_summary, + "timestamp": time.time(), + "arc_status": arc_status, + } + if key_moments: + entry["key_moments"] = key_moments + regular.setdefault("call_history", []).append(entry) regular["last_call"] = time.time() self._save() print(f"[Regulars] Updated {regular['name']} call history ({len(regular['call_history'])} calls)") return print(f"[Regulars] Regular {regular_id} not found for update") + def add_relationship(self, regular_id: str, other_name: str, + rel_type: str, context: str): + """Track a relationship between regulars""" + for regular in self._regulars: + if regular["id"] == regular_id: + regular.setdefault("relationships", {})[other_name] = { + "type": rel_type, + "context": context, + } + self._save() + print(f"[Regulars] {regular['name']} → {other_name}: {rel_type}") + return + regular_caller_service = RegularCallerService() diff --git a/backend/services/tts.py b/backend/services/tts.py index 4fe5a57..aa2bff3 100644 --- a/backend/services/tts.py +++ b/backend/services/tts.py @@ -130,6 +130,89 @@ INWORLD_SPEED_OVERRIDES = { } DEFAULT_INWORLD_SPEED = 1.1 # Slight bump for all voices +# Voice profiles — perceptual dimensions for each Inworld voice. +# Used by style-to-voice matching to pair caller personalities with fitting voices. +# weight: vocal depth/richness (light, medium, heavy) +# energy: default speaking animation (low, medium, high) +# warmth: friendliness/openness in the voice (cool, neutral, warm) +# age_feel: perceived speaker age (young, middle, mature) +VOICE_PROFILES = { + # --- Male voices --- + # Known characterizations from INWORLD_VOICES mapping and usage + "Alex": {"weight": "light", "energy": "high", "warmth": "warm", "age_feel": "young"}, # energetic, expressive, mildly nasal + "Edward": {"weight": "medium", "energy": "high", "warmth": "neutral", "age_feel": "middle"}, # fast-talking, emphatic, streetwise + "Shaun": {"weight": "medium", "energy": "high", "warmth": "warm", "age_feel": "middle"}, # friendly, dynamic, conversational + "Craig": {"weight": "heavy", "energy": "low", "warmth": "cool", "age_feel": "mature"}, # older British, refined, articulate + "Timothy": {"weight": "light", "energy": "high", "warmth": "warm", "age_feel": "young"}, # lively, upbeat American + "Dennis": {"weight": "medium", "energy": "high", "warmth": "warm", "age_feel": "middle"}, # energetic, default voice + "Ronald": {"weight": "heavy", "energy": "medium", "warmth": "neutral", "age_feel": "mature"}, # gruff, authoritative + "Theodore": {"weight": "heavy", "energy": "low", "warmth": "warm", "age_feel": "mature"}, # slow, deliberate + "Blake": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"}, + "Carter": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"}, + "Clive": {"weight": "heavy", "energy": "low", "warmth": "cool", "age_feel": "mature"}, + "Mark": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"}, + "Sebastian": {"weight": "medium", "energy": "medium", "warmth": "cool", "age_feel": "middle"}, # used by Silas (cult leader) & Chip + "Elliot": {"weight": "light", "energy": "medium", "warmth": "warm", "age_feel": "young"}, # used by Otis (comedian) + # Remaining male pool voices + "Arjun": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"}, + "Brian": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"}, + "Callum": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "young"}, + "Derek": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"}, + "Ethan": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "young"}, + "Evan": {"weight": "light", "energy": "medium", "warmth": "neutral", "age_feel": "young"}, + "Gareth": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"}, + "Graham": {"weight": "heavy", "energy": "low", "warmth": "neutral", "age_feel": "mature"}, + "Grant": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"}, + "Hades": {"weight": "heavy", "energy": "low", "warmth": "cool", "age_feel": "mature"}, + "Hamish": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"}, + "Hank": {"weight": "heavy", "energy": "medium", "warmth": "warm", "age_feel": "mature"}, + "Jake": {"weight": "medium", "energy": "high", "warmth": "warm", "age_feel": "young"}, + "James": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"}, + "Jason": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"}, + "Liam": {"weight": "medium", "energy": "high", "warmth": "warm", "age_feel": "young"}, + "Malcolm": {"weight": "heavy", "energy": "low", "warmth": "cool", "age_feel": "mature"}, + "Mortimer": {"weight": "heavy", "energy": "low", "warmth": "cool", "age_feel": "mature"}, + "Nate": {"weight": "light", "energy": "high", "warmth": "warm", "age_feel": "young"}, + "Oliver": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"}, + "Rupert": {"weight": "medium", "energy": "low", "warmth": "cool", "age_feel": "mature"}, + "Simon": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"}, + "Tyler": {"weight": "light", "energy": "high", "warmth": "neutral", "age_feel": "young"}, + "Victor": {"weight": "heavy", "energy": "medium", "warmth": "cool", "age_feel": "mature"}, + "Vinny": {"weight": "medium", "energy": "high", "warmth": "warm", "age_feel": "middle"}, + # --- Female voices --- + # Known characterizations + "Hana": {"weight": "light", "energy": "high", "warmth": "warm", "age_feel": "young"}, # bright, expressive young + "Ashley": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"}, # warm, natural + "Wendy": {"weight": "medium", "energy": "low", "warmth": "cool", "age_feel": "mature"}, # posh, middle-aged British + "Sarah": {"weight": "light", "energy": "high", "warmth": "neutral", "age_feel": "middle"}, # fast-talking, questioning + "Deborah": {"weight": "medium", "energy": "low", "warmth": "warm", "age_feel": "mature"}, # gentle, elegant + "Olivia": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"}, + "Julia": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"}, # used by Angie (deadpan) + "Priya": {"weight": "light", "energy": "medium", "warmth": "warm", "age_feel": "young"}, + "Amina": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"}, # used by Charlene (bragger) + "Tessa": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"}, # used by Lucille + "Kelsey": {"weight": "light", "energy": "medium", "warmth": "neutral", "age_feel": "young"}, # used by Maxine (quiet/nervous) + # Remaining female pool voices + "Anjali": {"weight": "light", "energy": "medium", "warmth": "warm", "age_feel": "young"}, + "Celeste": {"weight": "light", "energy": "medium", "warmth": "cool", "age_feel": "middle"}, + "Chloe": {"weight": "light", "energy": "high", "warmth": "warm", "age_feel": "young"}, + "Claire": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"}, + "Darlene": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "mature"}, + "Elizabeth": {"weight": "medium", "energy": "medium", "warmth": "cool", "age_feel": "mature"}, + "Jessica": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"}, + "Kayla": {"weight": "light", "energy": "high", "warmth": "warm", "age_feel": "young"}, + "Lauren": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"}, + "Loretta": {"weight": "medium", "energy": "low", "warmth": "warm", "age_feel": "mature"}, + "Luna": {"weight": "light", "energy": "medium", "warmth": "warm", "age_feel": "young"}, + "Marlene": {"weight": "medium", "energy": "low", "warmth": "neutral", "age_feel": "mature"}, + "Miranda": {"weight": "medium", "energy": "medium", "warmth": "cool", "age_feel": "middle"}, + "Pippa": {"weight": "light", "energy": "high", "warmth": "warm", "age_feel": "young"}, + "Saanvi": {"weight": "light", "energy": "medium", "warmth": "warm", "age_feel": "young"}, + "Serena": {"weight": "medium", "energy": "medium", "warmth": "cool", "age_feel": "middle"}, + "Veronica": {"weight": "medium", "energy": "medium", "warmth": "cool", "age_feel": "middle"}, + "Victoria": {"weight": "medium", "energy": "low", "warmth": "cool", "age_feel": "mature"}, +} + def preprocess_text_for_kokoro(text: str) -> str: """ diff --git a/frontend/css/style.css b/frontend/css/style.css index 10bf4a2..941081d 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -12,6 +12,7 @@ --text-muted: #9a8b78; --radius: 12px; --radius-sm: 8px; + --transition: 0.2s ease; } * { @@ -169,6 +170,7 @@ section { padding: 16px; border-radius: var(--radius); border: 1px solid rgba(232, 121, 29, 0.08); + transition: border-color var(--transition), box-shadow var(--transition); } section h2 { @@ -197,6 +199,30 @@ section h2 { cursor: pointer; font-size: 0.85rem; transition: all 0.2s; + display: flex; + align-items: center; + gap: 5px; + justify-content: center; + position: relative; +} + +.energy-dot { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; + flex-shrink: 0; +} + +.shape-badge { + font-size: 0.6rem; + background: rgba(232, 121, 29, 0.25); + color: var(--accent); + padding: 1px 4px; + border-radius: 3px; + font-weight: bold; + letter-spacing: 0.5px; + flex-shrink: 0; } .caller-btn:hover { @@ -217,8 +243,15 @@ section h2 { margin-bottom: 12px; } +/* Call action buttons row */ +.call-actions { + display: flex; + gap: 8px; + margin-top: 8px; +} + .hangup-btn { - width: 100%; + flex: 1; background: var(--accent-red); color: white; border: none; @@ -229,6 +262,103 @@ section h2 { transition: background 0.2s; } +.wrapup-btn { + flex: 1; + background: #7a6020; + color: #f0d060; + border: 2px solid #d4a030; + padding: 12px; + border-radius: var(--radius-sm); + cursor: pointer; + font-weight: bold; + transition: all 0.2s; +} + +.wrapup-btn:hover:not(:disabled) { + background: #d4a030; + color: #1a1209; +} + +.wrapup-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.wrapup-btn.active { + background: #d4a030; + color: #1a1209; + animation: wrapup-pulse 1.5s ease-in-out infinite; +} + +@keyframes wrapup-pulse { + 0%, 100% { box-shadow: 0 0 8px rgba(212, 160, 48, 0.4); } + 50% { box-shadow: 0 0 16px rgba(212, 160, 48, 0.8); } +} + +/* Caller info panel */ +.caller-info-panel { + background: var(--bg-light); + border: 1px solid rgba(232, 121, 29, 0.15); + border-radius: var(--radius-sm); + padding: 10px 12px; + margin: 8px 0; +} + +.caller-info-row { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 6px; +} + +.info-badge { + font-size: 0.75rem; + padding: 2px 8px; + border-radius: 10px; + font-weight: 600; +} + +.info-badge.shape { + background: rgba(232, 121, 29, 0.2); + color: var(--accent); +} + +.info-badge.energy { + color: white; + font-size: 0.7rem; +} + +.info-badge.emotion { + background: rgba(154, 139, 120, 0.2); + color: var(--text-muted); + font-style: italic; +} + +.caller-signature { + font-size: 0.8rem; + color: var(--accent); + margin-bottom: 4px; + font-style: italic; +} + +.caller-situation { + font-size: 0.8rem; + color: var(--text-muted); + line-height: 1.3; +} + +.caller-background-full { + margin-top: 8px; + font-size: 0.75rem; + color: var(--text-muted); +} + +.caller-background-full summary { + cursor: pointer; + color: var(--text-muted); + font-size: 0.7rem; +} + .hangup-btn:hover { background: #e03030; } @@ -399,10 +529,11 @@ section h2 { } } -.soundboard { +.soundboard-pinned { display: grid; - grid-template-columns: repeat(6, 1fr); - gap: 8px; + grid-template-columns: repeat(3, 1fr); + gap: 10px; + margin-bottom: 10px; } .sound-btn { @@ -416,6 +547,43 @@ section h2 { transition: all 0.1s; } +.sound-btn.pinned { + padding: 18px 12px; + font-size: 1rem; + font-weight: 700; + border-width: 2px; +} + +.sound-btn.pin-cheer { + border-color: #5a8a3c; + color: #7abf52; +} +.sound-btn.pin-cheer:hover { + background: #5a8a3c; + border-color: #5a8a3c; + color: #fff; +} + +.sound-btn.pin-applause { + border-color: var(--accent); + color: var(--accent); +} +.sound-btn.pin-applause:hover { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} + +.sound-btn.pin-boo { + border-color: var(--accent-red); + color: #e85050; +} +.sound-btn.pin-boo:hover { + background: var(--accent-red); + border-color: var(--accent-red); + color: #fff; +} + .sound-btn:hover { background: var(--accent); border-color: var(--accent); @@ -426,6 +594,57 @@ section h2 { transform: scale(0.95); } +.soundboard-toggle { + width: 100%; + background: none; + border: 1px solid rgba(232, 121, 29, 0.1); + border-radius: var(--radius-sm); + color: var(--text-muted); + padding: 8px; + cursor: pointer; + font-size: 0.8rem; + margin-bottom: 10px; + transition: all 0.2s; +} + +.soundboard-toggle:hover { + color: var(--text); + border-color: rgba(232, 121, 29, 0.3); +} + +.toggle-arrow { + font-size: 0.7rem; + margin-left: 4px; +} + +.soundboard-grid { + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 8px; +} + +/* Keyboard shortcut labels */ +.shortcut-label { + display: inline-block; + font-size: 0.6rem; + color: var(--text-muted); + background: rgba(232, 121, 29, 0.08); + border: 1px solid rgba(232, 121, 29, 0.12); + border-radius: 3px; + padding: 1px 4px; + margin-left: 6px; + font-family: 'Monaco', 'Menlo', monospace; + vertical-align: middle; + opacity: 0.7; +} + +.caller-btn .shortcut-label { + display: block; + margin: 3px auto 0; + margin-left: auto; + width: fit-content; +} + /* Modal */ .modal { position: fixed; @@ -776,3 +995,213 @@ section h2 { .email-preview { font-size: 0.8rem; color: var(--text-muted); line-height: 1.3; } .email-item .vm-actions { margin-top: 0.25rem; } .email-body-expanded { margin-top: 0.4rem; padding: 0.5rem; background: rgba(232, 121, 29, 0.08); border-radius: var(--radius-sm); font-size: 0.85rem; line-height: 1.5; white-space: pre-wrap; max-height: 200px; overflow-y: auto; } + +/* === Visual Polish === */ + +/* 1. Thinking pulse on chat when waiting for AI */ +@keyframes thinking-pulse { + 0%, 100% { border-color: rgba(232, 121, 29, 0.06); } + 50% { border-color: rgba(232, 121, 29, 0.3); } +} + +.chat-log.thinking { + animation: thinking-pulse 1.5s ease-in-out infinite; +} + +/* 3 & 5. Active call section glow + chat highlight when call is live */ +.callers-section.call-active { + border-color: rgba(232, 121, 29, 0.35); + box-shadow: 0 0 16px rgba(232, 121, 29, 0.1); +} + +.chat-section.call-active { + border-color: rgba(232, 121, 29, 0.25); + box-shadow: 0 0 12px rgba(232, 121, 29, 0.06); +} + +/* 7. Compact media row — Music / Ads / Idents side by side */ +.media-row { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + grid-column: span 2; +} + +@media (max-width: 700px) { + .media-row { + grid-template-columns: 1fr; + grid-column: span 1; + } +} + +.media-row .music-section { + padding: 12px; +} + +.media-row .music-section h2 { + font-size: 0.75rem; + margin-bottom: 8px; +} + +.media-row .music-section select { + padding: 6px 8px; + font-size: 0.8rem; + margin-bottom: 6px; +} + +.media-row .music-controls { + gap: 4px; +} + +.media-row .music-controls button { + padding: 6px 10px; + font-size: 0.8rem; +} + +/* Devon (Intern) */ +.message.devon { + border-left: 3px solid #4ab5a0; + padding-left: 0.5rem; + background: rgba(74, 181, 160, 0.06); +} + +.message.devon strong { + color: #4ab5a0; +} + +.devon-bar { + margin-bottom: 10px; +} + +.devon-ask-row { + display: flex; + gap: 6px; + align-items: center; +} + +.devon-input { + flex: 1; + padding: 8px 10px; + background: var(--bg); + color: var(--text); + border: 1px solid rgba(74, 181, 160, 0.2); + border-radius: var(--radius-sm); + font-size: 0.85rem; +} + +.devon-input:focus { + outline: none; + border-color: #4ab5a0; +} + +.devon-input::placeholder { + color: var(--text-muted); +} + +.devon-ask-btn { + background: #4ab5a0; + color: #fff; + border: none; + padding: 8px 14px; + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 0.85rem; + font-weight: 600; + transition: background 0.2s; + white-space: nowrap; +} + +.devon-ask-btn:hover { + background: #5cc5b0; +} + +.devon-interject-btn { + background: var(--bg); + color: #4ab5a0; + border: 1px solid rgba(74, 181, 160, 0.25); + padding: 8px 10px; + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 0.8rem; + transition: all 0.2s; + white-space: nowrap; +} + +.devon-interject-btn:hover { + border-color: #4ab5a0; + background: rgba(74, 181, 160, 0.1); +} + +.devon-monitor-label { + display: flex; + align-items: center; + gap: 4px; + font-size: 0.75rem; + color: var(--text-muted); + cursor: pointer; + white-space: nowrap; +} + +.devon-monitor-label input[type="checkbox"] { + accent-color: #4ab5a0; +} + +.devon-suggestion { + display: flex; + align-items: center; + gap: 8px; + margin-top: 6px; + padding: 8px 12px; + background: rgba(74, 181, 160, 0.08); + border: 1px solid rgba(74, 181, 160, 0.25); + border-radius: var(--radius-sm); + animation: devon-pulse 2s ease-in-out infinite; +} + +.devon-suggestion.hidden { + display: none !important; +} + +@keyframes devon-pulse { + 0%, 100% { border-color: rgba(74, 181, 160, 0.25); } + 50% { border-color: rgba(74, 181, 160, 0.6); } +} + +.devon-suggestion-text { + flex: 1; + font-size: 0.85rem; + color: #4ab5a0; + font-weight: 600; +} + +.devon-play-btn { + background: #4ab5a0; + color: #fff; + border: none; + padding: 4px 12px; + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 0.8rem; + font-weight: 600; + transition: background 0.2s; +} + +.devon-play-btn:hover { + background: #5cc5b0; +} + +.devon-dismiss-btn { + background: none; + color: var(--text-muted); + border: 1px solid rgba(232, 121, 29, 0.15); + padding: 4px 10px; + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 0.8rem; + transition: all 0.2s; +} + +.devon-dismiss-btn:hover { + color: var(--text); + border-color: rgba(232, 121, 29, 0.3); +} diff --git a/frontend/index.html b/frontend/index.html index 14e9bfe..308d78e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -50,11 +50,23 @@
No active call
- - + +
+ + +
@@ -84,6 +96,21 @@
+
+
+ + + + +
+ +
@@ -91,36 +118,36 @@
- -
-

Music

- -
- - - -
-
+ +
+
+

Music

+ +
+ + + +
+
- -
-

Ads

- -
- - -
-
+
+

Ads

+ +
+ + +
+
- -
-

Idents

- -
- - -
-
+
+

Idents

+ +
+ + +
+
+
@@ -251,6 +278,6 @@ - + diff --git a/frontend/js/app.js b/frontend/js/app.js index 56464a5..e7e32b2 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -14,6 +14,8 @@ let lastLogCount = 0; // Track lists let tracks = []; let sounds = []; +let isMusicPlaying = false; +let soundboardExpanded = false; // --- Helpers --- @@ -165,6 +167,49 @@ function initEventListeners() { stopRecording(); }); + // Keyboard shortcuts + document.addEventListener('keydown', e => { + if (_isTyping()) return; + // Don't fire shortcuts when a modal is open (except Escape to close it) + const modalOpen = document.querySelector('.modal:not(.hidden)'); + if (e.key === 'Escape') { + if (modalOpen) { + modalOpen.classList.add('hidden'); + e.preventDefault(); + } + return; + } + if (modalOpen) return; + + const key = e.key.toLowerCase(); + // 1-9, 0: Start call with caller in that slot + if (/^[0-9]$/.test(key)) { + e.preventDefault(); + const idx = key === '0' ? 9 : parseInt(key) - 1; + const btns = document.querySelectorAll('.caller-btn'); + if (btns[idx]) btns[idx].click(); + return; + } + switch (key) { + case 'h': + e.preventDefault(); + hangup(); + break; + case 'w': + e.preventDefault(); + wrapUp(); + break; + case 'm': + e.preventDefault(); + toggleMusic(); + break; + case 'd': + e.preventDefault(); + document.getElementById('devon-input')?.focus(); + break; + } + }); + // Type button document.getElementById('type-btn')?.addEventListener('click', () => { document.getElementById('type-modal')?.classList.remove('hidden'); @@ -194,6 +239,31 @@ function initEventListeners() { document.getElementById('ident-play-btn')?.addEventListener('click', playIdent); document.getElementById('ident-stop-btn')?.addEventListener('click', stopIdent); + // Devon (Intern) + document.getElementById('devon-ask-btn')?.addEventListener('click', () => { + const input = document.getElementById('devon-input'); + if (input?.value.trim()) { + askDevon(input.value.trim()); + input.value = ''; + } + }); + document.getElementById('devon-input')?.addEventListener('keydown', e => { + if (e.key === 'Enter') { + e.preventDefault(); + const input = e.target; + if (input.value.trim()) { + askDevon(input.value.trim()); + input.value = ''; + } + } + }); + document.getElementById('devon-interject-btn')?.addEventListener('click', interjectDevon); + document.getElementById('devon-monitor')?.addEventListener('change', e => { + toggleInternMonitor(e.target.checked); + }); + document.getElementById('devon-play-btn')?.addEventListener('click', playDevonSuggestion); + document.getElementById('devon-dismiss-btn')?.addEventListener('click', dismissDevonSuggestion); + // Settings document.getElementById('settings-btn')?.addEventListener('click', async () => { document.getElementById('settings-modal')?.classList.remove('hidden'); @@ -209,6 +279,9 @@ function initEventListeners() { }); document.getElementById('refresh-ollama')?.addEventListener('click', refreshOllamaModels); + // Wrap-up button + document.getElementById('wrapup-btn')?.addEventListener('click', wrapUp); + // Real caller hangup document.getElementById('hangup-real-btn')?.addEventListener('click', async () => { await fetch('/api/hangup/real', { method: 'POST' }); @@ -413,12 +486,34 @@ async function loadCallers() { if (!grid) return; grid.innerHTML = ''; - data.callers.forEach(caller => { + data.callers.forEach((caller, idx) => { const btn = document.createElement('button'); btn.className = 'caller-btn'; if (caller.returning) btn.classList.add('returning'); - btn.textContent = caller.returning ? `\u2605 ${caller.name}` : caller.name; btn.dataset.key = caller.key; + + let html = ''; + if (caller.energy_level) { + const energyColors = { low: '#4a7ab5', medium: '#5a8a3c', high: '#e8791d', very_high: '#cc2222' }; + const color = energyColors[caller.energy_level] || '#9a8b78'; + html += ``; + } + html += caller.returning ? `\u2605 ${caller.name}` : `${caller.name}`; + if (caller.call_shape && caller.call_shape !== 'standard') { + const shapeLabels = { + escalating_reveal: 'ER', am_i_the_asshole: 'AITA', confrontation: 'VS', + celebration: '\u{1F389}', quick_hit: 'QH', bait_and_switch: 'B&S', + the_hangup: 'HU', reactive: 'RE' + }; + const label = shapeLabels[caller.call_shape] || caller.call_shape.substring(0, 2).toUpperCase(); + html += `${label}`; + } + // Shortcut label: 1-9 for first 9, 0 for 10th + if (idx < 10) { + const shortcutKey = idx === 9 ? '0' : String(idx + 1); + html += `${shortcutKey}`; + } + btn.innerHTML = html; btn.addEventListener('click', () => startCall(caller.key, caller.name)); grid.appendChild(btn); }); @@ -443,6 +538,8 @@ async function startCall(key, name) { const data = await res.json(); currentCaller = { key, name }; + document.querySelector('.callers-section')?.classList.add('call-active'); + document.querySelector('.chat-section')?.classList.add('call-active'); // Check if real caller is active (three-way scenario) const realCallerActive = document.getElementById('real-caller-info') && @@ -455,6 +552,8 @@ async function startCall(key, name) { } document.getElementById('hangup-btn').disabled = false; + const wrapupBtn = document.getElementById('wrapup-btn'); + if (wrapupBtn) { wrapupBtn.disabled = false; wrapupBtn.classList.remove('active'); } // Show AI caller in active call indicator const aiInfo = document.getElementById('ai-caller-info'); @@ -462,13 +561,25 @@ async function startCall(key, name) { if (aiInfo) aiInfo.classList.remove('hidden'); if (aiName) aiName.textContent = name; - // Show caller background in disclosure triangle - const bgDetails = document.getElementById('caller-background-details'); - const bgEl = document.getElementById('caller-background'); - if (bgDetails && bgEl && data.background) { - bgEl.textContent = data.background; - bgDetails.classList.remove('hidden'); + // Show caller info panel with structured data + const infoPanel = document.getElementById('caller-info-panel'); + if (infoPanel && data.caller_info) { + const ci = data.caller_info; + const energyColors = { low: '#4a7ab5', medium: '#5a8a3c', high: '#e8791d', very_high: '#cc2222' }; + const shapeBadge = document.getElementById('caller-shape-badge'); + const energyBadge = document.getElementById('caller-energy-badge'); + const emotionBadge = document.getElementById('caller-emotion'); + const signature = document.getElementById('caller-signature'); + const situation = document.getElementById('caller-situation'); + if (shapeBadge) shapeBadge.textContent = (ci.call_shape || 'standard').replace(/_/g, ' '); + if (energyBadge) { energyBadge.textContent = (ci.energy_level || '').replace('_', ' '); energyBadge.style.background = energyColors[ci.energy_level] || '#9a8b78'; } + if (emotionBadge) emotionBadge.textContent = ci.emotional_state || ''; + if (signature) signature.textContent = ci.signature_detail ? `"${ci.signature_detail}"` : ''; + if (situation) situation.textContent = ci.situation_summary || ''; + infoPanel.classList.remove('hidden'); } + const bgEl = document.getElementById('caller-background'); + if (bgEl && data.background) bgEl.textContent = data.background; document.querySelectorAll('.caller-btn').forEach(btn => { btn.classList.toggle('active', btn.dataset.key === key); @@ -512,12 +623,17 @@ async function hangup() { currentCaller = null; isProcessing = false; hideStatus(); + document.querySelector('.callers-section')?.classList.remove('call-active'); + document.querySelector('.chat-section')?.classList.remove('call-active'); document.getElementById('call-status').textContent = 'No active call'; document.getElementById('hangup-btn').disabled = true; + const wrapBtn = document.getElementById('wrapup-btn'); + if (wrapBtn) { wrapBtn.disabled = true; wrapBtn.classList.remove('active'); } document.querySelectorAll('.caller-btn').forEach(btn => btn.classList.remove('active')); - // Hide caller background + // Hide caller info panel and background + document.getElementById('caller-info-panel')?.classList.add('hidden'); const bgDetails2 = document.getElementById('caller-background-details'); if (bgDetails2) bgDetails2.classList.add('hidden'); @@ -527,6 +643,26 @@ async function hangup() { } +async function wrapUp() { + if (!currentCaller) return; + try { + await fetch('/api/wrap-up', { method: 'POST' }); + const wrapupBtn = document.getElementById('wrapup-btn'); + if (wrapupBtn) wrapupBtn.classList.add('active'); + log(`Wrapping up ${currentCaller.name}...`); + } catch (err) { + console.error('wrapUp error:', err); + } +} + +function toggleMusic() { + if (isMusicPlaying) { + stopMusic(); + } else { + playMusic(); + } +} + // --- Server-Side Recording --- async function startRecording() { if (!currentCaller || isProcessing) return; @@ -722,11 +858,13 @@ async function playMusic() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ track, action: 'play' }) }); + isMusicPlaying = true; } async function stopMusic() { await fetch('/api/music/stop', { method: 'POST' }); + isMusicPlaying = false; } @@ -845,14 +983,60 @@ async function loadSounds() { if (!board) return; board.innerHTML = ''; + const pinnedNames = ['cheer', 'applause', 'boo']; + const pinned = []; + const rest = []; + sounds.forEach(sound => { + const lower = (sound.name || sound.file || '').toLowerCase(); + if (pinnedNames.some(p => lower.includes(p))) { + pinned.push(sound); + } else { + rest.push(sound); + } + }); + + // Pinned buttons — always visible + const pinnedRow = document.createElement('div'); + pinnedRow.className = 'soundboard-pinned'; + pinned.forEach(sound => { const btn = document.createElement('button'); - btn.className = 'sound-btn'; + btn.className = 'sound-btn pinned'; + const lower = (sound.name || sound.file || '').toLowerCase(); + if (lower.includes('cheer')) btn.classList.add('pin-cheer'); + else if (lower.includes('applause')) btn.classList.add('pin-applause'); + else if (lower.includes('boo')) btn.classList.add('pin-boo'); btn.textContent = sound.name; btn.addEventListener('click', () => playSFX(sound.file)); - board.appendChild(btn); + pinnedRow.appendChild(btn); }); - console.log('Loaded', sounds.length, 'sounds'); + board.appendChild(pinnedRow); + + // Collapsible section for remaining sounds + if (rest.length > 0) { + const toggle = document.createElement('button'); + toggle.className = 'soundboard-toggle'; + toggle.innerHTML = 'More Sounds '; + toggle.addEventListener('click', () => { + soundboardExpanded = !soundboardExpanded; + grid.classList.toggle('hidden', !soundboardExpanded); + toggle.querySelector('.toggle-arrow').innerHTML = soundboardExpanded ? '▲' : '▼'; + }); + board.appendChild(toggle); + + const grid = document.createElement('div'); + grid.className = 'soundboard-grid hidden'; + rest.forEach(sound => { + const btn = document.createElement('button'); + btn.className = 'sound-btn'; + btn.textContent = sound.name; + btn.addEventListener('click', () => playSFX(sound.file)); + grid.appendChild(btn); + }); + board.appendChild(grid); + } + + console.log('Loaded', sounds.length, 'sounds', `(${pinned.length} pinned)`); } catch (err) { console.error('loadSounds error:', err); } @@ -972,6 +1156,8 @@ function addMessage(sender, text) { className += ' host'; } else if (sender === 'System') { className += ' system'; + } else if (sender === 'DEVON') { + className += ' devon'; } else if (sender.includes('(caller)') || sender.includes('Caller #')) { className += ' real-caller'; } else { @@ -1008,12 +1194,14 @@ function showStatus(text) { status.textContent = text; status.classList.remove('hidden'); } + document.getElementById('chat')?.classList.add('thinking'); } function hideStatus() { const status = document.getElementById('status'); if (status) status.classList.add('hidden'); + document.getElementById('chat')?.classList.remove('thinking'); } @@ -1298,9 +1486,17 @@ async function fetchConversationUpdates() { } else if (msg.type === 'caller_queued') { // Queue poll will pick this up, just ensure it refreshes fetchQueue(); + } else if (msg.type === 'intern_response') { + addMessage('DEVON', msg.text); + } else if (msg.type === 'intern_suggestion') { + showDevonSuggestion(msg.text); } } } + // Check for intern suggestion in polling response + if (data.intern_suggestion) { + showDevonSuggestion(data.intern_suggestion.text); + } } catch (err) {} } @@ -1512,3 +1708,83 @@ async function deleteEmail(id) { log('Failed to delete email: ' + err.message); } } + + +// --- Devon (Intern) --- + +async function askDevon(question) { + addMessage('You', `Devon, ${question}`); + log(`[Devon] Looking up: ${question}`); + try { + const res = await safeFetch('/api/intern/ask', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ question }), + }); + if (res.text) { + addMessage('DEVON', res.text); + log(`[Devon] Responded (tools: ${(res.sources || []).join(', ') || 'none'})`); + } else { + log('[Devon] No response'); + } + } catch (err) { + log('[Devon] Error: ' + err.message); + } +} + +async function interjectDevon() { + log('[Devon] Checking for interjection...'); + try { + const res = await safeFetch('/api/intern/interject', { method: 'POST' }); + if (res.text) { + addMessage('DEVON', res.text); + log('[Devon] Interjected'); + } else { + log('[Devon] Nothing to add'); + } + } catch (err) { + log('[Devon] Interject error: ' + err.message); + } +} + +async function toggleInternMonitor(enabled) { + try { + await safeFetch('/api/intern/monitor', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled }), + }); + log(`[Devon] Monitor ${enabled ? 'on' : 'off'}`); + } catch (err) { + log('[Devon] Monitor toggle error: ' + err.message); + } +} + +function showDevonSuggestion(text) { + const el = document.getElementById('devon-suggestion'); + const textEl = el?.querySelector('.devon-suggestion-text'); + if (el && textEl) { + textEl.textContent = text ? `Devon: "${text.substring(0, 60)}${text.length > 60 ? '...' : ''}"` : 'Devon has something'; + el.classList.remove('hidden'); + } +} + +async function playDevonSuggestion() { + try { + const res = await safeFetch('/api/intern/suggestion/play', { method: 'POST' }); + if (res.text) { + addMessage('DEVON', res.text); + } + document.getElementById('devon-suggestion')?.classList.add('hidden'); + log('[Devon] Played suggestion'); + } catch (err) { + log('[Devon] Play suggestion error: ' + err.message); + } +} + +async function dismissDevonSuggestion() { + try { + await safeFetch('/api/intern/suggestion/dismiss', { method: 'POST' }); + document.getElementById('devon-suggestion')?.classList.add('hidden'); + } catch (err) {} +} diff --git a/website/how-it-works.html b/website/how-it-works.html index 169101e..e43721e 100644 --- a/website/how-it-works.html +++ b/website/how-it-works.html @@ -4,7 +4,7 @@ How It Works — Luke at the Roost - + @@ -79,6 +79,7 @@

A Person Is Born

Every caller starts as a blank slate. The system generates a complete identity: name, age, job, hometown, and personality. Each caller gets a unique speaking style — some ramble, some are blunt, some deflect with humor. They have relationships, vehicles, strong food opinions, nostalgic memories, and reasons for being up this late. They know what they were watching on TV, what errand they ran today, and what song was on the radio before they called.

+

But it goes deeper than backstory. Every caller is built with a structured call shape — maybe an escalating reveal where they start casual and drop a bombshell halfway through, a bait-and-switch where the real issue isn't what they said at first, or a slow burn that builds to an emotional peak. They have energy levels, emotional states, and signature details — a phrase they keep coming back to, a nervous tic in how they talk, a specific detail that makes the whole thing feel real. And each caller is matched to a voice that fits their personality. A 60-year-old trucker from Lordsburg doesn't sound like a 23-year-old barista from Tucson.

Some callers become regulars. The system tracks returning callers across episodes — they remember past conversations, reference things they talked about before, and their stories evolve over time. You'll hear Leon check in about going back to school, or Shaniqua update you on her situation at work. They're not reset between shows.

And some callers are drunk, high, or flat-out unhinged. They'll call with conspiracy theories about pigeons being government drones, existential crises about whether fish know they're wet, or to confess they accidentally set their kitchen on fire trying to make grilled cheese at 3 AM.

@@ -87,12 +88,12 @@ 160
- Personality Layers - 300+ + Voice Profiles + 68
- Towns with Real Knowledge - 55 + Call Shapes + 8 types
Returning Regulars @@ -115,6 +116,7 @@

They Have a Reason to Call

Some callers have a problem — a fight with a neighbor, a situation at work, something weighing on them at 2 AM. Others call to geek out about Severance, argue about poker strategy, or share something they read about quantum physics. The system draws from over 1,000 unique calling reasons across dozens of categories — problems, stories, advice-seeking, gossip, and deep-dive topics. Every caller has a purpose, not just a script.

+

The whole thing is tuned for comedy. Not "AI tries to be funny" comedy — more like the energy of late-night call-in radio meets stand-up meets the kind of confessions you only hear at 2 AM. Some calls are genuinely heartfelt. Some are absurd. Some start serious and go completely sideways. The system knows how to build a call for comedic timing — when to hold back a detail, when to escalate, when to let the awkward silence do the work. It's not random chaos; it's structured chaos.

70% @@ -132,7 +134,9 @@
4

The Conversation Is Real

-

Luke talks to each caller using push-to-talk, just like a real radio show. His voice is transcribed in real time, sent to an AI that responds in character, and then converted to speech using a voice engine — all in a few seconds. The AI doesn't just answer questions; it reacts, gets emotional, goes on tangents, and remembers what was said earlier in the show. Callers even react to previous callers — "Hey Luke, I heard that guy Tony earlier and I got to say, he's full of it." It makes the show feel like a living community, not isolated calls.

+

Luke talks to each caller using push-to-talk, just like a real radio show. His voice is transcribed in real time, sent to an AI that responds in character, and then converted to speech using a voice engine — all in a few seconds. The AI doesn't just answer questions; it reacts, gets emotional, goes on tangents, and remembers what was said earlier in the show.

+

Callers don't just exist in isolation — the show tracks what's been discussed and matches callers thematically. If someone just called about a messy divorce, the next caller who references marriage didn't pick that topic randomly. The system scores previous callers by topic overlap and decides whether the new caller should reference them, disagree with them, or build on what they said. It tracks the show's overall energy so the pacing doesn't flatline — a heavy emotional call might be followed by something lighter, and vice versa.

+

And when a call has run its course, Luke can hit "Wrap It Up" — a signal that tells the caller to wind things down gracefully. Instead of an abrupt hang-up, the caller gets the hint and starts wrapping up their thought, says their goodbyes, and exits naturally. Just like a real radio host giving the "time's up" hand signal through the glass.

@@ -154,6 +158,15 @@
7
+
+

Devon the Intern

+

Every show needs someone to yell at. Devon is the show's intern — a 23-year-old NMSU grad who's way too eager, occasionally useful, and frequently wrong. He's not a caller; he's a permanent fixture of the show. When Luke needs a fact checked, a topic researched, or someone to blame for a technical issue, Devon's there.

+

Devon has real tools. He can search the web, pull up news headlines, look things up on Wikipedia, and read articles — all live during the show. When a caller claims that octopuses have three hearts, Devon's already looking it up. Sometimes he interjects on his own when he thinks he has something useful to add. Sometimes he's right. Sometimes Luke tells him to shut up. He monitors conversations in the background and pipes up with suggestions that the host can play or dismiss. He's the kind of intern who tries really hard and occasionally nails it.

+
+
+ +
+
8

The Control Room

The entire show runs through a custom-built control panel. Luke manages callers, plays music and sound effects, runs ads and station idents, monitors the call queue, and controls everything from one screen. Audio is routed across seven independent channels simultaneously — host mic, AI caller voices, live phone audio, music, sound effects, ads, and station idents all on separate tracks. The website shows a live on-air indicator so listeners know when to call in.

@@ -428,7 +441,7 @@
-
8
+
9

Multi-Stem Recording

During every show, the system records six separate audio stems simultaneously: host microphone, AI caller voices, music, sound effects, ads, and station idents. Each stem is captured as an independent WAV file with sample-accurate alignment. This gives full control over the final mix — like having a recording studio's multitrack session, not just a flat recording.

@@ -454,7 +467,7 @@
-
9
+
10

Dialog Editing in REAPER

Before the automated pipeline runs, the raw stems are loaded into REAPER for dialog editing. A custom Lua script analyzes voice tracks to detect silence gaps — the dead air between caller responses, TTS latency pauses, and gaps where Luke is reading the control room. The script strips these silences and ripple-edits all tracks in sync so ads, idents, and music shift with the dialog cuts. Protected regions marked as ads or idents are preserved — the script knows not to remove silence during an ad break even if the voice tracks are quiet. This tightens a raw two-hour session into a focused episode without cutting any content.

@@ -462,7 +475,7 @@
-
10
+
11

Post-Production Pipeline

Once the show ends, a 15-step automated pipeline processes the raw stems into a broadcast-ready episode. Ads and sound effects are hard-limited to prevent clipping. The host mic gets a high-pass filter, de-essing, and breath reduction. Voice tracks are compressed — the host gets aggressive spoken-word compression for consistent levels, callers get telephone EQ to sound like real phone calls. All stems are level-matched, music is ducked under dialog and muted during ads, then everything is mixed to stereo with panning and width. A bus compressor glues the final mix together before silence trimming, fades, and EBU R128 loudness normalization.

@@ -488,7 +501,7 @@
-
11
+
12

Automated Publishing

A single command takes a finished episode and handles everything: the audio is transcribed using MLX Whisper running on Apple Silicon GPU to generate full-text transcripts, then an LLM analyzes the transcript to write the episode title, description, and chapter markers with timestamps. The episode is uploaded to the podcast server and directly to YouTube with chapters baked into the description. Chapters and transcripts are attached to the RSS metadata, all media is synced to a global CDN, and social posts are pushed to eight platforms — all from one command.

@@ -514,7 +527,7 @@
-
12
+
13

Automated Social Clips

No manual editing, no scheduling tools. After each episode, an LLM reads the full transcript and picks the best moments — funny exchanges, wild confessions, heated debates. Each clip is automatically extracted, transcribed with word-level timestamps, then polished by a second LLM pass that fixes punctuation, capitalization, and misheard words while preserving timing. The clips are rendered as vertical video with speaker-labeled captions and the show's branding. A third LLM writes platform-specific descriptions and hashtags. Then clips are uploaded directly to YouTube Shorts and Bluesky via their APIs, and pushed to Instagram Reels, Facebook Reels, Mastodon, Nostr, LinkedIn, Threads, and TikTok — nine platforms, zero manual work.

@@ -540,7 +553,7 @@
-
13
+
14

Global Distribution

Episodes are served through a CDN edge network for fast, reliable playback worldwide. The RSS feed is automatically updated and picked up by Spotify, Apple Podcasts, YouTube, and every other podcast app. The website pulls the live feed to show episodes with embedded playback, full transcripts, and chapter navigation — all served through Cloudflare with edge caching. From recording to available on every platform, the whole pipeline is automated end-to-end.

@@ -597,7 +610,7 @@

They Listen to Each Other

-

Callers aren't isolated — they hear what happened earlier in the show. A caller might disagree with the last guy, back someone up, or call in specifically because of something another caller said. The show builds on itself.

+

Callers aren't isolated — the system matches callers thematically to what's already been discussed. A caller might disagree with the last guy, back someone up, or call in because something another caller said hit close to home. The show tracks energy and pacing so conversations build naturally, not randomly.