[{"data":1,"prerenderedAt":9157},["ShallowReactive",2],{"navigation":3,"/blog/building-a-linkedin-ml-persona-part-2-infrastructure":147,"blog-surround-/blog/building-a-linkedin-ml-persona-part-2-infrastructure":1227,"related-posts-/blog/building-a-linkedin-ml-persona-part-2-infrastructure":1232},[4,70],{"title":5,"path":6,"stem":7,"children":8,"page":69},"Gallery","/gallery","gallery",[9,13,17,21,25,29,33,37,41,45,49,53,57,61,65],{"title":10,"path":11,"stem":12},"2013 Paintings","/gallery/2013-paintings","gallery/1.2013-paintings",{"title":14,"path":15,"stem":16},"Subway","/gallery/subway","gallery/10.subway",{"title":18,"path":19,"stem":20},"Umbrellas","/gallery/umbrellas","gallery/11.umbrellas",{"title":22,"path":23,"stem":24},"Blutac","/gallery/blutac","gallery/12.blutac",{"title":26,"path":27,"stem":28},"I Must Not Write On The Wall","/gallery/i-must-not-write-on-the-wall","gallery/14.i-must-not-write-on-the-wall",{"title":30,"path":31,"stem":32},"Lost Glove Rug","/gallery/lost-glove-rug","gallery/15.lost-glove-rug",{"title":34,"path":35,"stem":36},"Oddities","/gallery/oddities","gallery/16.oddities",{"title":38,"path":39,"stem":40},"Drawings","/gallery/drawings","gallery/2.drawings",{"title":42,"path":43,"stem":44},"Grids","/gallery/grids","gallery/3.grids",{"title":46,"path":47,"stem":48},"Marks","/gallery/marks","gallery/4.marks",{"title":50,"path":51,"stem":52},"Veils","/gallery/veils","gallery/5.veils",{"title":54,"path":55,"stem":56},"Space Above Tall Buildings","/gallery/above","gallery/6.above",{"title":58,"path":59,"stem":60},"Balloons and Shoes","/gallery/balloons-and-shoes","gallery/7.balloons-and-shoes",{"title":62,"path":63,"stem":64},"Christmas Trees","/gallery/christmas-trees","gallery/8.christmas-trees",{"title":66,"path":67,"stem":68},"Flags","/gallery/flags","gallery/9.flags",false,{"title":71,"path":72,"stem":73,"children":74,"page":69},"Blog","/blog","blog",[75,79,83,87,91,95,99,103,107,111,115,119,123,127,131,135,139,143],{"title":76,"path":77,"stem":78},"QWERTY Keyboard Installation","/blog/qwerty-keyboard-installation","blog/08.qwerty-keyboard-installation",{"title":80,"path":81,"stem":82},"Hyperlight - Single Bit Communication","/blog/hyperlight-single-bit-communication","blog/09.hyperlight-single-bit-communication",{"title":84,"path":85,"stem":86},"Dirty Fingerprints Screensaver","/blog/dirty-fingerprints-screensaver","blog/10.dirty-fingerprints-screensaver",{"title":88,"path":89,"stem":90},"Why it takes 2 people to design 1 UX","/blog/why-it-takes-2-people-to-design-1-ux","blog/11.why-it-takes-2-people-to-design-1-ux",{"title":92,"path":93,"stem":94},"Why half is not equal to 0.5","/blog/why-half-is-not-equal-to-point-five","blog/12.why-half-is-not-equal-to-point-five",{"title":96,"path":97,"stem":98},"Zero Tech Debt Will Kill Your Startup","/blog/zero-tech-debt-will-kill-your-startup","blog/13.zero-tech-debt-will-kill-your-startup",{"title":100,"path":101,"stem":102},"iTunes in the Browser: Building a SPA in 2004","/blog/itunes-in-the-browser-building-a-spa-in-2004","blog/14.itunes-in-the-browser-building-a-spa-in-2004",{"title":104,"path":105,"stem":106},"Spotify for the Mall: FYE's listening kiosk in 2005","/blog/spotify-for-the-mall-fye-listening-kiosk-in-2005","blog/15.spotify-for-the-mall-fye-listening-kiosk-in-2005",{"title":108,"path":109,"stem":110},"Click Where It Hurts: The WebMD Symptom Checker Story","/blog/click-where-it-hurts-the-webmd-symptom-checker-story","blog/16.click-where-it-hurts-the-webmd-symptom-checker-story",{"title":112,"path":113,"stem":114},"AI is Coming for Your APIs","/blog/ai-is-coming-for-your-apis","blog/17.ai-is-coming-for-your-apis",{"title":116,"path":117,"stem":118},"Real-Time Geometric Rendering of Web Page Architecture","/blog/real-time-geometric-rendering-web-page-architecture","blog/18.real-time-geometric-rendering-web-page-architecture",{"title":120,"path":121,"stem":122},"Amazon's AI Gamble: The Real Cost of Panic Investing","/blog/amazons-ai-gamble-the-real-cost-of-panic-investing","blog/19.amazons-ai-gamble-the-real-cost-of-panic-investing",{"title":124,"path":125,"stem":126},"Cursor's \"Trust Me Bro\" Vibes, Backed by RL Isn't Quite There Yet","/blog/cursor-s-trust-me-bro-ux-backed-by-rl-isn-t-quite-there-yet","blog/20.cursor-s-trust-me-bro-ux-backed-by-rl-isn-t-quite-there-yet",{"title":128,"path":129,"stem":130},"Building a LinkedIn ML Persona: Part 1 - The Data Harvest","/blog/building-a-linkedin-ml-persona-part-1-the-data-harvest","blog/21.building-a-linkedin-ml-persona-part-1-the-data-harvest",{"title":132,"path":133,"stem":134},"Why LLMs Can't Play Chess","/blog/why-llms-cant-play-chess","blog/22.why-llms-cant-play-chess",{"title":136,"path":137,"stem":138},"Ask Nico: Turning My Static Blog into a RAG-Powered LLM Chat","/blog/ask-nico-turning-my-static-blog-into-a-rag-powered-llm-chat","blog/23.ask-nico-turning-my-static-blog-into-a-rag-powered-llm-chat",{"title":140,"path":141,"stem":142},"Building a LinkedIn ML Persona: Part 2 - Infrastructure","/blog/building-a-linkedin-ml-persona-part-2-infrastructure","blog/24.building-a-linkedin-ml-persona-part-2-infrastructure",{"title":144,"path":145,"stem":146},"Build Small Things: A Manifesto for Successful Software Development","/blog/build-small-things","blog/build-small-things",{"id":148,"title":140,"askNicoQuestion":149,"authors":150,"badge":156,"body":159,"date":1219,"description":1220,"extension":1221,"image":1222,"meta":1224,"navigation":474,"path":141,"seo":1225,"stem":142,"__hash__":1226},"posts/blog/24.building-a-linkedin-ml-persona-part-2-infrastructure.md","How did I serve a LinkedIn clone on Cloud Run for pennies?",[151],{"name":152,"to":153,"avatar":154},"Nico Westerdale","https://www.nicowesterdale.com",{"src":155},"https://storage.googleapis.com/nico-westerdale-images/info/nico-headshot-2026.jpg",[157],{"label":158},"AI",{"type":160,"value":161,"toc":1201},"minimark",[162,176,179,182,185,190,193,201,325,339,342,346,349,355,358,361,393,396,418,450,453,519,534,537,541,544,551,556,559,562,567,570,575,581,585,593,598,601,606,621,624,659,666,671,674,679,682,709,712,717,720,724,727,732,739,745,749,756,768,772,778,783,794,797,801,804,807,867,870,881,888,895,898,906,909,915,918,921,936,940,947,950,953,964,981,985,992,1019,1029,1032,1071,1080,1083,1087,1094,1097,1102,1105,1110,1113,1116,1121,1124,1127,1135,1138,1141,1149,1152,1157,1160,1163,1168,1171,1175,1178,1181,1184,1187,1190,1197],[163,164,165,166,170,171,175],"p",{},"In ",[167,168,169],"a",{"href":129},"Part 1",", we extracted what passes as my LinkedIn soul into a ",[172,173,174],"code",{},".jsonl"," file. We proved that a large, smart model can generate perfect training data for a small, dumb one.",[163,177,178],{},"Now, we need the engine to run it.",[163,180,181],{},"Anyone with a credit card can spin up a GPU in the cloud and host a model. I wanted to do it on a serverless, scale-to-zero infrastructure that costs absolutely nothing when I'm not using it.",[163,183,184],{},"Welcome to Part 2: LinkedIn ML Infrastructure.",[186,187,189],"h2",{"id":188},"the-false-start-gemma","The False Start: Gemma",[163,191,192],{},"Before we could even think about servers, we had to actually train the model.",[163,194,195,196,200],{},"My initial plan was to be brutally cheap. I wanted to use Google's native open weights model, ",[197,198,199],"strong",{},"Gemma 2B",". It's tiny, it's efficient, and it lives in the same ecosystem as my data. I fired up the Kubeflow pipeline for it on Vertex AI and hit an immediate wall of 403 Permission Denied errors:",[202,203,208],"pre",{"className":204,"code":205,"language":206,"meta":207,"style":207},"language-json shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","{\n  \"error\": {\n    \"code\": 403,\n    \"message\": \"Failed to download package from uri \\\"https://us-central1-kfp.pkg.dev/ml-pipeline/large-language-model-pipelines/tune-large-model/v3.0.0\\\".\",\n    \"status\": \"PERMISSION_DENIED\"\n  }\n}\n","json","",[172,209,210,219,238,258,293,313,319],{"__ignoreMap":207},[211,212,215],"span",{"class":213,"line":214},"line",1,[211,216,218],{"class":217},"sMK4o","{\n",[211,220,222,225,229,232,235],{"class":213,"line":221},2,[211,223,224],{"class":217},"  \"",[211,226,228],{"class":227},"spNyl","error",[211,230,231],{"class":217},"\"",[211,233,234],{"class":217},":",[211,236,237],{"class":217}," {\n",[211,239,241,244,247,249,251,255],{"class":213,"line":240},3,[211,242,243],{"class":217},"    \"",[211,245,172],{"class":246},"sBMFI",[211,248,231],{"class":217},[211,250,234],{"class":217},[211,252,254],{"class":253},"sbssI"," 403",[211,256,257],{"class":217},",\n",[211,259,261,263,266,268,270,273,277,281,284,286,289,291],{"class":213,"line":260},4,[211,262,243],{"class":217},[211,264,265],{"class":246},"message",[211,267,231],{"class":217},[211,269,234],{"class":217},[211,271,272],{"class":217}," \"",[211,274,276],{"class":275},"sfazB","Failed to download package from uri ",[211,278,280],{"class":279},"sTEyZ","\\\"",[211,282,283],{"class":275},"https://us-central1-kfp.pkg.dev/ml-pipeline/large-language-model-pipelines/tune-large-model/v3.0.0",[211,285,280],{"class":279},[211,287,288],{"class":275},".",[211,290,231],{"class":217},[211,292,257],{"class":217},[211,294,296,298,301,303,305,307,310],{"class":213,"line":295},5,[211,297,243],{"class":217},[211,299,300],{"class":246},"status",[211,302,231],{"class":217},[211,304,234],{"class":217},[211,306,272],{"class":217},[211,308,309],{"class":275},"PERMISSION_DENIED",[211,311,312],{"class":217},"\"\n",[211,314,316],{"class":213,"line":315},6,[211,317,318],{"class":217},"  }\n",[211,320,322],{"class":213,"line":321},7,[211,323,324],{"class":217},"}\n",[163,326,327,328,331,332,335,336,288],{},"First from the Artifact Registry package, then from my own private bucket. Even after fixing both, I realized the real problem: Gemma 2B on Vertex AI required a ",[197,329,330],{},"Full Fine-Tune",". This meant retraining all two billion parameters of the model. For my tiny dataset of 334 rows, this was a non-starter. Then I found ",[197,333,334],{},"Llama 3.2",". Not only was it available as a managed one-click tuning job that bypassed my IAM headaches, but it supported ",[197,337,338],{},"LoRA (Low-Rank Adaptation)",[163,340,341],{},"LoRA is the surgical approach: instead of rewriting the whole model, you're effectively adding a little training data over the top. It's faster, cheaper, and fundamentally better suited for tiny, high-quality datasets like mine.",[186,343,345],{"id":344},"the-tuning-breaking-the-helpful-assistant","The Tuning: Breaking the \"Helpful Assistant\"",[163,347,348],{},"My first attempt at tuning the Llama model was a disaster. I used standard settings, ran it for a few epochs, and got a model that sounded exactly like a helpful AI assistant. It refused to be cynical. It refused to be me. When I asked it to describe a personal struggle, it flatly rejected the premise:",[350,351,352],"blockquote",{},[163,353,354],{},"\"I don't have personal experiences, but I can share a hypothetical scenario... I can provide a simple and clear explanation of a concept without struggling because I am a machine learning model designed to provide accurate and informative responses.\"",[163,356,357],{},"Modern LLMs are safety trained to be polite, helpful, and bland, like most sycophantic AI chatbots. I wanted cynical dry wit. Breaking that conditioning requires aggression.",[163,359,360],{},"The recipe that finally worked was surprisingly heavy-handed for such a small dataset (334 rows):",[362,363,364,375,384],"ul",{},[365,366,367,370,371,374],"li",{},[197,368,369],{},"Epochs:"," ",[172,372,373],{},"8"," (We're hammering the lesson home).",[365,376,377,370,380,383],{},[197,378,379],{},"Learning Rate:",[172,381,382],{},"0.0002"," (High enough to force change, low enough to avoid damaging the system's core logic).",[365,385,386,370,389,392],{},[197,387,388],{},"LoRA Rank:",[172,390,391],{},"16"," (A standard size for the adapter layers).",[163,394,395],{},"After 8 full passes through my depolished data, the 8B LoRA model finally started to show that the data was taking hold, albeit in a weird, trying-too-hard AI clone kind of way.",[163,397,398,399,405,406,409,410,413,414,417],{},"To test each tuning run, I wrote a ",[167,400,404],{"href":401,"rel":402},"https://github.com/iconico/linkedin-ai-persona-server",[403],"nofollow","predictor script",": a Flask server that downloads the model weights from GCS, loads them into memory, and exposes three endpoints. ",[172,407,408],{},"/predict"," for Q&A, ",[172,411,412],{},"/rewrite"," for style transfer, and ",[172,415,416],{},"/prompt"," for experimenting with custom system prompts. Every request gets wrapped in the strict Llama 3 Instruct format:",[202,419,423],{"className":420,"code":421,"language":422,"meta":207,"style":207},"language-markdown shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","\u003C|begin_of_text|>\u003C|start_header_id|>system\u003C|end_header_id|>\n{system_prompt}\u003C|eot_id|>\n\u003C|start_header_id|>user\u003C|end_header_id|>\n{user_input}\u003C|eot_id|>\n\u003C|start_header_id|>assistant\u003C|end_header_id|>\n","markdown",[172,424,425,430,435,440,445],{"__ignoreMap":207},[211,426,427],{"class":213,"line":214},[211,428,429],{},"\u003C|begin_of_text|>\u003C|start_header_id|>system\u003C|end_header_id|>\n",[211,431,432],{"class":213,"line":221},[211,433,434],{},"{system_prompt}\u003C|eot_id|>\n",[211,436,437],{"class":213,"line":240},[211,438,439],{},"\u003C|start_header_id|>user\u003C|end_header_id|>\n",[211,441,442],{"class":213,"line":260},[211,443,444],{},"{user_input}\u003C|eot_id|>\n",[211,446,447],{"class":213,"line":295},[211,448,449],{},"\u003C|start_header_id|>assistant\u003C|end_header_id|>\n",[163,451,452],{},"The core of the script is surprisingly simple:",[202,454,458],{"className":455,"code":456,"language":457,"meta":207,"style":207},"language-python shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","tokenizer = AutoTokenizer.from_pretrained(local_model_path)\ntokenizer.chat_template = None  # The Lobotomy (more on this later)\n\nmodel = AutoModelForCausalLM.from_pretrained(\n    local_model_path,\n    dtype=torch.float16,\n    device_map=\"auto\"\n)\n\npipe = pipeline(\"text-generation\", model=model, tokenizer=tokenizer, max_new_tokens=256)\nllm = HuggingFacePipeline(pipeline=pipe)\n","python",[172,459,460,465,470,476,481,486,491,496,502,507,513],{"__ignoreMap":207},[211,461,462],{"class":213,"line":214},[211,463,464],{},"tokenizer = AutoTokenizer.from_pretrained(local_model_path)\n",[211,466,467],{"class":213,"line":221},[211,468,469],{},"tokenizer.chat_template = None  # The Lobotomy (more on this later)\n",[211,471,472],{"class":213,"line":240},[211,473,475],{"emptyLinePlaceholder":474},true,"\n",[211,477,478],{"class":213,"line":260},[211,479,480],{},"model = AutoModelForCausalLM.from_pretrained(\n",[211,482,483],{"class":213,"line":295},[211,484,485],{},"    local_model_path,\n",[211,487,488],{"class":213,"line":315},[211,489,490],{},"    dtype=torch.float16,\n",[211,492,493],{"class":213,"line":321},[211,494,495],{},"    device_map=\"auto\"\n",[211,497,499],{"class":213,"line":498},8,[211,500,501],{},")\n",[211,503,505],{"class":213,"line":504},9,[211,506,475],{"emptyLinePlaceholder":474},[211,508,510],{"class":213,"line":509},10,[211,511,512],{},"pipe = pipeline(\"text-generation\", model=model, tokenizer=tokenizer, max_new_tokens=256)\n",[211,514,516],{"class":213,"line":515},11,[211,517,518],{},"llm = HuggingFacePipeline(pipeline=pipe)\n",[163,520,521,522,525,526,529,530,533],{},"The ",[172,523,524],{},"/health"," endpoint starts at ",[172,527,528],{},"503"," and flips to ",[172,531,532],{},"200"," only after the model finishes loading, which becomes critical later when we deploy this to Cloud Run.",[163,535,536],{},"I had two main modes in the script. The first mode was a \"Predictor\" for answering questions, the idea being that the model should respond in my voice to any question just like a regular chatbot. The second mode was a Rewriter, so if you fed it a few lines of text it would rewrite the text in my voice. The difference between success and failure came down to how I filled in the system prompt.",[186,538,540],{"id":539},"the-identity-crisis","The Identity Crisis",[163,542,543],{},"Getting to a model that approximated my voice was a strange road of identity confusion. Small models have strong \"Identity Gravitation\": they want to fall back to whatever concept of you they found in their pre-training data.",[163,545,546,547,550],{},"When I ran an early 3B full-tune (3 epochs at a low ",[172,548,549],{},"1e-7"," learning rate) and gave it an empty rewrite prompt, it replied with absolute confidence:",[350,552,553],{},[163,554,555],{},"\"Please go ahead and provide the text you'd like me to rewrite in the voice of Nico Westerdale, the charming and smooth-talking bartender from the TV show 'Dead to Me'.\"",[163,557,558],{},"I had to look that up, but the bartender in 'Dead to Me' is called Slade, played by actor John Ennis. I have no idea where it got that from.",[163,560,561],{},"I ran it again, and this time:",[350,563,564],{},[163,565,566],{},"\"I'm ready to rewrite. What's the text you'd like me to transform into the voice of Nico Westerdale, the infamous villain from the Fallout 4 universe?\"",[163,568,569],{},"It even had a phase where it tried to sound like a motivational poster:",[350,571,572],{},[163,573,574],{},"\"(in a smooth, velvety voice) Ah, the core principle. You want to know what drives me, what sets me apart from the rest? Well, let me tell you, my friend. My core principle is quite simple, yet profound. ... 'Ambition is the fire that burns within.'\"",[163,576,577,578,580],{},"What I was finding out was that this light tune (3 epochs at a tiny ",[172,579,549],{}," learning rate) was just enough to make the model aware of my name, but not enough to care about who I actually was. My name is unique enough that the small model had no real idea who I was, but creative enough to start inventing a life for me. It was filling the void of my identity with whatever fictional tropes it had lying around in its pre-training data.",[186,582,584],{"id":583},"the-assistant-sleeper-agent","The \"Assistant\" Sleeper Agent",[163,586,587,588,592],{},"It was through this predictor that I discovered a bizarre problem. In the early \"Predictor\" tests, I used a polite prompt: ",[589,590,591],"em",{},"\"You are an AI assistant that mimics the professional persona of Nico Westerdale.\""," The model took the word \"mimics\" far too literally. When asked about my core principle, it replied with this sycophantic corporate drivel:",[350,594,595],{},[163,596,597],{},"\"(in a smooth, sophisticated tone) Ah, my core principle. Well, that's a question that gets to the heart of what I do, and what I stand for. You see, I'm not just a machine, I'm a facilitator.\"",[163,599,600],{},"Or better yet:",[350,602,603],{},[163,604,605],{},"\"(in a smooth, velvety voice) Ah, the pursuit of artificial intelligence. It's a realm where the boundaries of human ingenuity are pushed...\"",[163,607,608,609,612,613,616,617,620],{},"It was ",[589,610,611],{},"performing"," the task, but narrating its own actions. The Llama 3 tokenizer has a ",[172,614,615],{},"chat_template"," baked into it that enforces a \"User -> Assistant\" dialogue format. When you use the standard Hugging Face pipeline, it automatically wraps your prompt in this hidden structure, which triggers the model's deepest helpful assistant training and overrides my fine-tuning. That ",[172,618,619],{},"tokenizer.chat_template = None"," line in the predictor? That's the lobotomy: manually disabling the template to stop the \"Assistant\" persona from waking up.",[163,622,623],{},"But even the lobotomy wasn't enough. I was still fighting the model's pre-training because of my own verbosity. My original system prompt was polite and structured exactly how the tutorials led me to believe this was done:",[202,625,627],{"className":420,"code":626,"language":422,"meta":207,"style":207},"\u003C|begin_of_text|>\u003C|start_header_id|>system\u003C|end_header_id|>\n\nYou are an AI assistant that mimics the professional persona of Nico Westerdale.\u003C|eot_id|>\n\u003C|start_header_id|>user\u003C|end_header_id|>\n\nAnswer the following question in his voice: {question}\u003C|eot_id|>\n\u003C|start_header_id|>assistant\u003C|end_header_id|>\n",[172,628,629,633,637,642,646,650,655],{"__ignoreMap":207},[211,630,631],{"class":213,"line":214},[211,632,429],{},[211,634,635],{"class":213,"line":221},[211,636,475],{"emptyLinePlaceholder":474},[211,638,639],{"class":213,"line":240},[211,640,641],{},"You are an AI assistant that mimics the professional persona of Nico Westerdale.\u003C|eot_id|>\n",[211,643,644],{"class":213,"line":260},[211,645,439],{},[211,647,648],{"class":213,"line":295},[211,649,475],{"emptyLinePlaceholder":474},[211,651,652],{"class":213,"line":315},[211,653,654],{},"Answer the following question in his voice: {question}\u003C|eot_id|>\n",[211,656,657],{"class":213,"line":321},[211,658,449],{},[163,660,661,662,665],{},"I tried variants like ",[589,663,664],{},"\"Answer the following question in his voice\""," or even an empty system prompt, hoping the fine-tuning would just take over. It didn't. The model's base \"helpful assistant\" persona kept coming through. Every time I used an instruction, it would retreat into these cringe-worthy, self-aware meta-commentaries:",[350,667,668],{},[163,669,670],{},"\"The perpetual conundrum of the AI conundrum. (chuckles) As a highly advanced language model, I've encountered numerous instances where I've struggled to convey a simple concept to a human user.\"",[163,672,673],{},"Or the existential philosopher:",[350,675,676],{},[163,677,678],{},"\"The inevitable question that gets to the heart of my artificial existence. As a highly advanced language model, I don't truly 'struggle' in the way humans do...\"",[163,680,681],{},"Finally I realized, with a bit of a facepalm moment, that if the model was trained to be me, I didn't have to tell it to be itself. I just had to let it speak. I replaced the entire paragraph of instructions with a single, imperative command:",[202,683,685],{"className":420,"code":684,"language":422,"meta":207,"style":207},"\u003C|begin_of_text|>\u003C|start_header_id|>system\u003C|end_header_id|>\n\nYou rewrite text in your own voice.\u003C|eot_id|>\u003C|start_header_id|>user\u003C|end_header_id|>\n\nRewrite the following text in your voice: {original_text}\u003C|eot_id|>\u003C|start_header_id|>assistant\u003C|end_header_id|>\n",[172,686,687,691,695,700,704],{"__ignoreMap":207},[211,688,689],{"class":213,"line":214},[211,690,429],{},[211,692,693],{"class":213,"line":221},[211,694,475],{"emptyLinePlaceholder":474},[211,696,697],{"class":213,"line":240},[211,698,699],{},"You rewrite text in your own voice.\u003C|eot_id|>\u003C|start_header_id|>user\u003C|end_header_id|>\n",[211,701,702],{"class":213,"line":260},[211,703,475],{"emptyLinePlaceholder":474},[211,705,706],{"class":213,"line":295},[211,707,708],{},"Rewrite the following text in your voice: {original_text}\u003C|eot_id|>\u003C|start_header_id|>assistant\u003C|end_header_id|>\n",[163,710,711],{},"Suddenly, the meta-commentary vanished. The \"facilitator\" was dead. When I fed it a dry note about my weekend plans, it didn't say \"Here is a rewrite...\" It just spoke:",[350,713,714],{},[163,715,716],{},"\"Took the weekend off to go bike packing. Great to get out, but I'm in for a world of hurt when I get back to this.\"",[163,718,719],{},"No \"as an AI,\" it's actually taking a weekend off and going bike packing! That's huge progress.",[186,721,723],{"id":722},"the-deployment-problem","The Deployment Problem",[163,725,726],{},"How do you serve a multi-gigabyte machine learning model? On Google Cloud Platform (GCP), there are three obvious paths. One is easy and expensive. One's a non-starter. One is obnoxious and cheap. This is no comment on my personality, but I chose the latter.",[728,729,731],"h3",{"id":730},"option-1-the-official-way-vertex-ai-endpoints","Option 1: The Official Way (Vertex AI Endpoints)",[163,733,734,735,738],{},"GCP has a dedicated service for this: ",[197,736,737],{},"Vertex AI Endpoints",". You take your trained model, click a few buttons, and it's deployed on an optimized, scalable infrastructure.",[163,740,741,744],{},[197,742,743],{},"The Catch:"," It's always on. You're paying for a GPU-enabled virtual machine to sit there, 24/7, waiting for a request that might never come. For a production system with constant traffic, this would cost hundreds of bucks a month. For this project, that's a pass.",[728,746,748],{"id":747},"option-2-the-serverless-way-cloud-functions","Option 2: The Serverless Way (Cloud Functions)",[163,750,751,752,755],{},"What about the cheapest serverless option? ",[197,753,754],{},"Cloud Functions",". You upload your code, and it only runs when called. Perfect, right?",[163,757,758,760,761,764,765],{},[197,759,743],{}," Cloud Functions are designed for short, stateless tasks. They have aggressive timeout limits and memory caps that are laughable for our use case. An 8B Llama in ",[172,762,763],{},"float16"," is about 16GB in memory. The cold start, the process of downloading the model from storage and loading it into memory, would take several minutes. The function would time out and die before it ever served a single request. Wrong tool for the job. ",[589,766,767],{},"(2nd gen Cloud Functions have since bumped to 32GB RAM and 60-minute timeouts, so the memory cap isn't the blocker it was, but there's still no GPU support and the cold start story is still painful.)",[728,769,771],{"id":770},"option-3-the-container-way-cloud-run","Option 3: The Container Way (Cloud Run)",[163,773,774,777],{},[197,775,776],{},"Cloud Run"," is a serverless platform for running containers. Like Cloud Functions, it can scale to zero, so we pay nothing when it's idle. But because it runs a standard Docker container, we get full control over the environment, the memory, and the startup process.",[163,779,780,782],{},[197,781,743],{}," Cloud Run is designed for web services, not for ML models.",[163,784,785,786,789,790,793],{},"To get the ",[197,787,788],{},"32GB of RAM"," required to hold an 8B Llama in memory without crashing, Google Cloud Run has a strict pairing rule: you can't just have RAM; you have to buy CPU too. I was forced to provision ",[197,791,792],{},"8 vCPUs"," just to get the memory. You pay that pairing whenever the instance is actually up. Scale to zero is what keeps idle at zero.",[163,795,796],{},"This is our path.",[186,798,800],{"id":799},"local-development","Local Development",[163,802,803],{},"Before deploying anything, you have to make it work locally.",[163,805,806],{},"My local machine has a decent NVIDIA GPU. Getting the Python script to use it was a nightmare.",[808,809,810,824,833,847,861],"ol",{},[365,811,812,815,816,819,820,823],{},[197,813,814],{},"Python Version Issues:"," I started with the latest Python (3.13). Big mistake. PyTorch, the core ML library, didn't have a stable, CUDA-enabled build for it. I had to downgrade to ",[197,817,818],{},"Python 3.11.9"," and, more importantly, learn to love virtual environments (",[172,821,822],{},".venv","). Never again will I pollute my global Python install.",[365,825,826,370,829,832],{},[197,827,828],{},"The CUDA Surprise:",[172,830,831],{},"pip install torch"," is a lie. It installs a CPU-only version. To get GPU acceleration, you have to uninstall it and then reinstall a specific version from a special URL, explicitly telling it which CUDA version you need.",[365,834,835,838,839,842,843,846],{},[197,836,837],{},"The GCS Path Flattening:"," Vertex AI doesn't just give you a model; it gives you a Russian Nesting Doll of folders. It writes weights to a path like ",[172,840,841],{},"tuned-models/postprocess/node-0/checkpoints/final/",". I had to write a custom download helper to \"flatten\" this structure into ",[172,844,845],{},"/tmp"," just so the Hugging Face tokenizer could find its own files.",[365,848,849,852,853,856,857,860],{},[197,850,851],{},"Working Code:"," After all that, the model ran. Really really slowly, but it ran. A single inference took 30 seconds. By default, models load in 32-bit precision (",[172,854,855],{},"float32","). By adding one line of code, ",[172,858,859],{},"dtype=torch.float16",", we tell it to use 16-bit half-precision. That enables the GPU's Tensor Cores, and suddenly, inference times dropped from 30 seconds to about 6 seconds. Decent enough.",[365,862,863,866],{},[197,864,865],{},"GPU?:"," My Windows Task Manager sat at 0% GPU utilization. I thought I was failing.",[163,868,869],{},"I even spent an hour chasing a \"Second GPU Phantom.\" My machine showed two GPUs in Task Manager. I tried to pin the model to the \"second\" one to keep my primary display lag-free, only to hit a wall:",[202,871,875],{"className":872,"code":873,"language":874,"meta":207,"style":207},"language-powershell shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","ValueError: Got device==1, device is required to be within [-1, 1)\n","powershell",[172,876,877],{"__ignoreMap":207},[211,878,879],{"class":213,"line":214},[211,880,873],{},[163,882,883,884,887],{},"It turns out the second \"GPU\" was just the integrated Intel chip. Intel has no CUDA. PyTorch only saw the NVIDIA, so ",[172,885,886],{},"device==1"," was never a real option.",[163,889,890,891,894],{},"Then I ran ",[172,892,893],{},"nvidia-smi"," in the terminal.",[163,896,897],{},"Idle (09:43:48):",[202,899,904],{"className":900,"code":902,"language":903},[901],"language-text","| NVIDIA-SMI 573.57                 Driver Version: 573.57         CUDA Version: 12.8     |\n|   0  NVIDIA RTX 3000 Ada Gene...  WDDM  |\n| N/A   41C    P8              1W /   40W |    6365MiB /   8188MiB |      0%      Default |\n","text",[172,905,902],{"__ignoreMap":207},[163,907,908],{},"During inference (09:43:51):",[202,910,913],{"className":911,"code":912,"language":903},[901],"| N/A   45C    P1             41W /   40W |    6365MiB /   8188MiB |     87%      Default |\n",[172,914,912],{"__ignoreMap":207},[163,916,917],{},"90% utilization and 6.3GB of VRAM occupied on a 40W laptop chip. That footprint is the 3B; the 8B model would not fit. Pro tip: Don't trust the Windows GUI with your MLOps; trust the terminal.",[186,919,776],{"id":920},"cloud-run",[163,922,923,924,927,928,931,932,935],{},"The deployment architecture is straightforward: a ",[172,925,926],{},"Dockerfile"," to build the container, a ",[172,929,930],{},"requirements.txt"," to list dependencies, and a ",[172,933,934],{},"predictor.py"," script running a Flask/Gunicorn web server.",[728,937,939],{"id":938},"thanks-google","Thanks Google",[163,941,942,943,946],{},"My most painful discovery was this. You can configure a Cloud Run service to use a GPU. You can configure it to scale to zero. You ",[197,944,945],{},"cannot"," do both at the same time.",[163,948,949],{},"To use a GPU, you must have a minimum of one instance running 24/7. This brings us right back to the Vertex AI Endpoint problem, at a cost of ~$290/month. For my ridiculous ML project to cost more than a cup of coffee was a clear failure.",[163,951,952],{},"I had to make a choice: be fast, or be cheap. I chose cheap. I abandoned the GPU and deployed on a CPU-only instance. This meant the cold start would be agonizingly slow.",[163,954,955],{},[589,956,957,958,963],{},"Update: I'm writing this up a few months later, and Google has since ",[167,959,962],{"href":960,"rel":961},"https://cloud.google.com/blog/products/serverless/cloud-run-gpus-are-now-generally-available",[403],"shipped GPU scale-to-zero on Cloud Run",". The $290/month trap is gone; you can now have an L4 GPU instance that costs $0 when idle and about $1.05/hour when it wakes up. The cold start is still there, but Google benchmarks a 4B model at ~19 seconds from zero to first token, so my 3-5 minute CPU agony would drop to under 30 seconds. If I were doing this today, I'd take that path.",[163,965,966],{},[589,967,968,969,974,975,980],{},"There are also platforms like ",[167,970,973],{"href":971,"rel":972},"https://modal.com",[403],"Modal"," that tackle cold starts more aggressively. Modal lets you snapshot GPU memory state, so instead of re-loading weights from scratch on every cold start, a new container restores from a checkpoint, skipping the expensive initialization. That's the fair comparison for a custom LoRA like mine. ",[167,976,979],{"href":977,"rel":978},"https://fireworks.ai",[403],"Fireworks"," keeps popular catalog models hot across their fleet and charges per token, so there's effectively no cold start, but that's a hosted model, not your own weights. For a side project serving one request every few days, Modal would have saved me a lot of pain.",[728,982,984],{"id":983},"cold-starts","Cold Starts",[163,986,987,988,991],{},"On my first deploy to Cloud Run the CPU-only cold start for this model takes about ",[197,989,990],{},"3 to 5 minutes",". It has to pull a multi-gigabyte container image, download the weights from GCS, and then load them into RAM. I hit the public URL too early. The server was up, but the weights were not:",[202,993,995],{"className":204,"code":994,"language":206,"meta":207,"style":207},"{\"error\":\"Model is not loaded yet\"}\n",[172,996,997],{"__ignoreMap":207},[211,998,999,1002,1004,1006,1008,1010,1012,1015,1017],{"class":213,"line":214},[211,1000,1001],{"class":217},"{",[211,1003,231],{"class":217},[211,1005,228],{"class":227},[211,1007,231],{"class":217},[211,1009,234],{"class":217},[211,1011,231],{"class":217},[211,1013,1014],{"class":275},"Model is not loaded yet",[211,1016,231],{"class":217},[211,1018,324],{"class":217},[163,1020,1021,1022,1025,1026,1028],{},"Cloud Run has a health check mechanism called a ",[197,1023,1024],{},"Startup Probe",". You give it an endpoint in your application (e.g., ",[172,1027,524],{},") and it'll repeatedly ping that endpoint during the startup process. If the probe fails too many times, Cloud Run kills the instance. The trick is to make the probe wait, so user traffic never sees that 503.",[163,1030,1031],{},"So how about this:",[808,1033,1034,1040,1047,1064],{},[365,1035,1036,1037,1039],{},"I created a ",[172,1038,524],{}," endpoint in my Flask app.",[365,1041,1042,1043,1046],{},"I designed it to return a ",[172,1044,1045],{},"503 Service Unavailable"," status by default.",[365,1048,1049,1050,1053,1054,1057,1058,1060,1061,288],{},"Only ",[589,1051,1052],{},"after"," the ",[172,1055,1056],{},"load_model()"," function successfully completes does a global variable flip, causing ",[172,1059,524],{}," to return a ",[172,1062,1063],{},"200 OK",[365,1065,1066,1067,1070],{},"In the Cloud Run configuration, I set the Startup Probe's timeout to ",[197,1068,1069],{},"600 seconds"," (10 minutes).",[163,1072,1073,1074,1076,1077,1079],{},"This forces Cloud Run to be incredibly patient. It'll sit there, pinging the ",[172,1075,524],{}," endpoint for up to 10 minutes, getting rejected again and again. It sees the service is \"unhealthy.\" But it waits. Then, finally, after 3 to 5 minutes, the model finishes loading, the endpoint flips to ",[172,1078,1063],{},", the probe passes, and Cloud Run declares the service \"healthy,\" ready to accept traffic.",[163,1081,1082],{},"It's not really what Cloud Run is supposed to be about, but it does allow a long-running, heavyweight process to initialize within a serverless environment designed for the exact opposite.",[186,1084,1086],{"id":1085},"the-results","The Results",[163,1088,1089,1090,1093],{},"We did it. We have a live, public URL for our custom-trained Llama model. It costs a few cents per invocation and ",[197,1091,1092],{},"zero dollars"," when idle. The first request is a coffee break, but subsequent requests are reasonably fast until the instance scales down.",[163,1095,1096],{},"More importantly, it actually sounds like me. Here are some of the raw rewrite outputs from the final API endpoint:",[163,1098,1099],{},[197,1100,1101],{},"The Successes:",[163,1103,1104],{},"When fed a news brief about Cursor's new Agent mode:",[350,1106,1107],{},[163,1108,1109],{},"\"Cursor 2.0's new Agent mode released yesterday is already leaning on the output of the model. The Composer model uses Reinforcement Learning, trained on aggregate patterns. Fast, impressive, but it doesn't learn from your codebase.\"",[163,1111,1112],{},"That tracks. I posted about Cursor 2.0, and I'm a big Cursor fan.",[163,1114,1115],{},"When fed a news story about LinkedIn quietly training on user posts:",[350,1117,1118],{},[163,1119,1120],{},"\"I just found out that LinkedIn has quietly added a new policy that allows them to use your profile data and posts to train AI models. Thanks LinkedIn, you had me going for a second there with the quiet part.\"",[163,1122,1123],{},"Love the snark on that one, A+ LinkedIn post.",[163,1125,1126],{},"When fed a dry corporate announcement about LinkedIn connections:",[350,1128,1129],{},[163,1130,1131,1132,231],{},"\"As a linkedIn user, I want to connect with other linkedIn users, so that they can fill my inbox with auto-generated messages promoting their services. ",[172,1133,1134],{},"#reallifeuserstories",[163,1136,1137],{},"Sarcastic, and it actually used my hashtag! I'm genuinely impressed!",[163,1139,1140],{},"When fed a snippet about OpenAI's style changes:",[350,1142,1143],{},[163,1144,1145,1146,231],{},"\"OpenAI is going to let you tell ChatGPT to stop using the em dash. Finally, a win for anyone who doesn't want their writing to look like it was generated by a lazy robot. ",[172,1147,1148],{},"#ai #writing",[163,1150,1151],{},"A bit dry, but okay.",[163,1153,1154],{},[197,1155,1156],{},"And the Also Ran:",[163,1158,1159],{},"It wasn't all success.",[163,1161,1162],{},"When I fed it a dry sentence about Microsoft Copilot, it replied:",[350,1164,1165],{},[163,1166,1167],{},"\"I'm DALL-E 2, and I want to be Nico Westerdale for a day. #ai #nico #microsoft\"",[163,1169,1170],{},"Okay nobody's perfect. Copilot certainly isn't.",[186,1172,1174],{"id":1173},"the-sunset","The Sunset",[163,1176,1177],{},"So it works.",[163,1179,1180],{},"I set out to build a trained ML model that could rewrite text in my own LinkedIn voice. I proved that you can serve an ML model on serverless infrastructure for peanuts if you're willing to wait for a while on startup. I showed that you don't need 10,000 rows of data to tune a persona; just 334 rows is enough to break a model's safety training and force it to adopt a voice, and it did so pretty decently for the tiny training set that I actually had.",[163,1182,1183],{},"Its cynical snarky posts made me consider that I could just set up a scheduler to consume the latest news and automate it to post on LinkedIn. It wouldn't be that hard, a daily job that crawled the web, wrote the story to blob storage, fired up the ML model on Cloud Run, waited the eternity for it to start up, fed it the news story clip and said to rewrite it in my voice, then save the rewrite back to storage. Then a short while later a scheduled job on Claude desktop could log into LinkedIn using the browser and grab the rewrite out of storage and post it. Or my OpenClaw bot could do the same running on my Raspberry Pi. Or I could script it with Playwright and Python. Whatever the path, it would work, the hard part's done.",[163,1185,1186],{},"I'm not doing that.",[163,1188,1189],{},"The experiment was a success, but frankly, the world only needs one Nico Westerdale posting on LinkedIn at a time.",[163,1191,1192,1193,288],{},"However, if you're tempted, then the code for the predictor server is on GitHub: ",[167,1194,1196],{"href":401,"rel":1195},[403],"linkedin-ai-persona-server",[1198,1199,1200],"style",{},"html pre.shiki code .sMK4o, html code.shiki .sMK4o{--shiki-light:#39ADB5;--shiki-default:#89DDFF;--shiki-dark:#89DDFF}html pre.shiki code .spNyl, html code.shiki .spNyl{--shiki-light:#9C3EDA;--shiki-default:#C792EA;--shiki-dark:#C792EA}html pre.shiki code .sBMFI, html code.shiki .sBMFI{--shiki-light:#E2931D;--shiki-default:#FFCB6B;--shiki-dark:#FFCB6B}html pre.shiki code .sbssI, html code.shiki .sbssI{--shiki-light:#F76D47;--shiki-default:#F78C6C;--shiki-dark:#F78C6C}html pre.shiki code .sfazB, html code.shiki .sfazB{--shiki-light:#91B859;--shiki-default:#C3E88D;--shiki-dark:#C3E88D}html pre.shiki code .sTEyZ, html code.shiki .sTEyZ{--shiki-light:#90A4AE;--shiki-default:#EEFFFF;--shiki-dark:#BABED8}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":207,"searchDepth":221,"depth":221,"links":1202},[1203,1204,1205,1206,1207,1212,1213,1217,1218],{"id":188,"depth":221,"text":189},{"id":344,"depth":221,"text":345},{"id":539,"depth":221,"text":540},{"id":583,"depth":221,"text":584},{"id":722,"depth":221,"text":723,"children":1208},[1209,1210,1211],{"id":730,"depth":240,"text":731},{"id":747,"depth":240,"text":748},{"id":770,"depth":240,"text":771},{"id":799,"depth":221,"text":800},{"id":920,"depth":221,"text":776,"children":1214},[1215,1216],{"id":938,"depth":240,"text":939},{"id":983,"depth":240,"text":984},{"id":1085,"depth":221,"text":1086},{"id":1173,"depth":221,"text":1174},"2026-09-13T00:00:00.000Z","I had the data to clone my persona, now I needed to serve it for pennies. This is a story of cloud architecture trade-offs, GPU pain, and a hack that makes Cloud Run work for ML.","md",{"src":1223},"https://storage.googleapis.com/nico-westerdale-images/blog/building-a-linkedin-ml-persona-part-2-infrastructure/cloning-part-2.jpg",{},{"title":140,"description":1220},"gwvAikmBAPFydosBuMz7ekybVJBL53a2eLfiOwNFLyE",[1228,1230],{"title":136,"path":137,"stem":138,"description":1229,"children":-1},"Here is how I used Nuxt, Markdown, and Vertex AI Search to turn my blog into a queryable LLM knowledge base that actually knows what I wrote.",{"title":144,"path":145,"stem":146,"description":1231,"children":-1},"Whether in project planning, process design, or individual work patterns, complexity creates project failure. This manifesto showcases pragmatic steps to create impactful software and a highly engaged organization capable of meaningful change.",[1233,7984,8382],{"id":1234,"title":136,"askNicoQuestion":1235,"authors":1236,"badge":1239,"body":1241,"date":7978,"description":1229,"extension":1221,"image":7979,"meta":7981,"navigation":474,"path":137,"seo":7982,"stem":138,"__hash__":7983},"posts/blog/23.ask-nico-turning-my-static-blog-into-a-rag-powered-llm-chat.md","How did I turn my blog into a talking knowledge base?",[1237],{"name":152,"to":153,"avatar":1238},{"src":155},[1240],{"label":158},{"type":160,"value":1242,"toc":7959},[1243,1251,1254,1257,1263,1267,1278,1284,1288,1291,1295,1302,1332,3076,3089,3098,3102,3111,3118,3140,3147,3157,3198,3205,3278,3290,3302,3305,3309,3312,3337,3340,3344,3347,3354,3358,3361,3368,3372,3375,3384,3395,3398,3575,3581,3777,3788,4633,4639,5607,5614,5617,5621,5628,5632,5642,6098,6109,6113,6131,6135,6150,7351,7358,7400,7404,7409,7412,7477,7486,7871,7884,7887,7910,7926,7933,7937,7940,7943,7946,7950,7956],[163,1244,1245,1246,1250],{},"I’ve been writing this blog for a while now. It’s built on a modified Nuxt setup; I'm a big fan of simplicity in all aspects of building and Vue's reusable components have always aligned with how I approach building projects. Nuxt's content system has some nice features, including server-side rendering, but beyond just being a blog I wanted an extensible, powerful system where I could experiment with ",[167,1247,1249],{"href":1248},"/the-lab","Lab projects"," and delve into the full power of an enterprise cloud stack if I wanted to. That's why I host the site on Firebase, which is, of course, backed by Google's world-class Google Cloud Platform (GCP).",[163,1252,1253],{},"Nuxt's blogging system is straightforward; it's a folder full of markdown files and some YAML metadata. If you're not familiar, these are far simpler than HTML markup as markdown files are very human-readable text content files. I'm writing one right now for this exact blog post. The build process that Nuxt has comes with configurations to spit out server-side HTML from the markdown, which means that everything is pre-rendered and blazing fast. The system works great for me, but as the content grows, I was hoping to do something a little more with the corpus that I'm steadily creating.",[163,1255,1256],{},"I realized that since I’m already using Nuxt and Markdown, and I have all the backend horsepower of GCP sitting there, I could quite easily take all the markdown I have and use that knowledge base to give my blog a voice.",[1258,1259],"lab-project-cta",{"description":1260,"href":1261,"title":1262},"Chat with my blog. Ask questions about my posts, my technical choices, or anything I've written.","/the-lab/ask-nico","Try Ask Nico",[186,1264,1266],{"id":1265},"chatting-to-my-blog","Chatting to my Blog",[163,1268,1269,1270,1273,1274,1277],{},"In my ",[167,1271,1272],{"href":133},"recent post about why LLMs can't play chess",", I explored how LLM models fail at state tracking and rule-bound logic because they are fundamentally pattern matchers, not reasoners. The same issue applies to personal knowledge. Generalist models like Claude or Gemini know what a \"generally good\" engineer thinks, but they don't know my specific architectural trade-offs or my rants about ",[167,1275,1276],{"href":113},"AI coming for your APIs",". They are sampling from a distribution that doesn't include my own content.",[163,1279,1280,1283],{},[197,1281,1282],{},"Ask Nico"," is my attempt to align an LLM with my own content. It’s a RAG (Retrieval-Augmented Generation) system built on a Vertex AI Search data store that's been seeded with the actual markdown files I’ve written. This allows anyone to, in effect, chat with my blog, using the Gemini LLM that's connected to the Vertex AI backend. This took some configuration, but it was surprisingly straightforward, and the responses are quite accurate.",[186,1285,1287],{"id":1286},"gcp-setup-vertex-ai-search-and-blob-storage","GCP Setup: Vertex AI Search and Blob Storage",[163,1289,1290],{},"To get the infrastructure in place. I needed three things: a Cloud Storage bucket to hold my content, a Vertex AI Search app, and a way to seed that content into the search index.",[728,1292,1294],{"id":1293},"_1-create-a-gcs-bucket","1. Create a GCS Bucket",[163,1296,1297,1298,1301],{},"The first step is simple: create a Cloud Storage bucket in the ",[172,1299,1300],{},"us-central1"," region that supports Vertex AI. This is where the transformed content will be staged. Private storage; we don't want it on the web.",[163,1303,1304,1305,1308,1309,1312,1313,1316,1317,1320,1321,1324,1325,1328,1329,234],{},"A Node.js script populates the bucket. It reads ",[172,1306,1307],{},".md"," and ",[172,1310,1311],{},".yml"," files from ",[172,1314,1315],{},"content/",", strips markdown to plain text, preserves YAML metadata as \"Key: Value\" blocks, injects a ",[172,1318,1319],{},"SOURCE_URL"," line so the RAG system can cite the original page, and uploads ",[172,1322,1323],{},".txt"," files to ",[172,1326,1327],{},"gs://your-bucket/ask-nico/"," preserving the folder structure. Create ",[172,1330,1331],{},"scripts/ask-nico-ingest/index.mjs",[202,1333,1337],{"className":1334,"code":1335,"language":1336,"meta":207,"style":207},"language-javascript shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","// scripts/ask-nico-ingest/index.mjs\n// Requires: pnpm add @google-cloud/storage\n// Run: GCP_PROJECT=123 ASK_NICO_GCS_BUCKET=my-bucket node scripts/ask-nico-ingest/index.mjs\n\nimport { readFileSync, readdirSync } from 'fs'\nimport { join, relative } from 'path'\nimport { fileURLToPath } from 'url'\nimport { Storage } from '@google-cloud/storage'\n\nconst __dirname = fileURLToPath(new URL('.', import.meta.url))\nconst ROOT = join(__dirname, '../..')\nconst CONTENT_DIR = join(ROOT, 'content')\n\nfunction toPlainText (text) {\n  return text\n    .replace(/\\*\\*([^*]+)\\*\\*/g, '$1')\n    .replace(/\\*([^*]+)\\*/g, '$1')\n    .replace(/^#+\\s+/gm, '')\n    .replace(/\\[([^\\]]+)\\]\\([^)]+\\)/g, '$1')\n    .replace(/`([^`]+)`/g, '$1')\n    .trim()\n}\n\nfunction processMarkdown (text, relPath) {\n  const sourcePath = relPath.replace(/\\.(md|yml)$/, '').replace(/\\\\/g, '/')\n  const sourceUrl = `SOURCE_URL: /${sourcePath}`\n  const match = text.match(/^---\\n([\\s\\S]*?)\\n---\\n?([\\s\\S]*)$/)\n  if (!match) return `${sourceUrl}\\n\\n${toPlainText(text)}`.trim()\n  const meta = match[1].split('\\n').map(l => l.trim()).filter(l => l && !l.startsWith('#')).join('\\n')\n  return `${sourceUrl}\\n${meta}\\n\\n${toPlainText(match[2])}`.trim()\n}\n\nfunction getFiles (dir, base = dir) {\n  const files = []\n  for (const ent of readdirSync(dir, { withFileTypes: true })) {\n    const full = join(dir, ent.name)\n    const rel = relative(base, full)\n    if (ent.isDirectory() && !ent.name.startsWith('.') && ent.name !== 'node_modules') {\n      files.push(...getFiles(full, base))\n    } else if (ent.isFile() && (ent.name.endsWith('.md') || ent.name.endsWith('.yml'))) {\n      files.push({ path: full, rel })\n    }\n  }\n  return files\n}\n\nasync function main () {\n  const bucketName = process.env.ASK_NICO_GCS_BUCKET\n  const projectId = process.env.GCP_PROJECT\n  if (!bucketName || !projectId) {\n    console.error('Set GCP_PROJECT and ASK_NICO_GCS_BUCKET')\n    process.exit(1)\n  }\n  const bucket = new Storage({ projectId }).bucket(bucketName)\n  const files = getFiles(CONTENT_DIR)\n  for (const { path: fp, rel } of files) {\n    const plain = processMarkdown(readFileSync(fp, 'utf-8'), rel)\n    if (!plain) continue\n    const gcsName = `ask-nico/${rel.replace(/\\.(md|yml)$/, '.txt').replace(/\\\\/g, '/')}`\n    await bucket.file(gcsName).save(plain, { contentType: 'text/plain' })\n    console.log('Uploaded', gcsName)\n  }\n  console.log('Done')\n}\n\nmain().catch(e => { console.error(e); process.exit(1) })\n","javascript",[172,1338,1339,1345,1350,1355,1359,1392,1417,1437,1457,1461,1508,1533,1559,1564,1584,1593,1638,1674,1710,1759,1795,1806,1811,1816,1837,1910,1935,1988,2036,2148,2190,2195,2200,2227,2240,2282,2310,2333,2399,2430,2504,2532,2538,2543,2551,2556,2561,2578,2601,2622,2646,2667,2684,2689,2725,2743,2775,2814,2831,2911,2960,2985,2990,3011,3016,3021],{"__ignoreMap":207},[211,1340,1341],{"class":213,"line":214},[211,1342,1344],{"class":1343},"sHwdD","// scripts/ask-nico-ingest/index.mjs\n",[211,1346,1347],{"class":213,"line":221},[211,1348,1349],{"class":1343},"// Requires: pnpm add @google-cloud/storage\n",[211,1351,1352],{"class":213,"line":240},[211,1353,1354],{"class":1343},"// Run: GCP_PROJECT=123 ASK_NICO_GCS_BUCKET=my-bucket node scripts/ask-nico-ingest/index.mjs\n",[211,1356,1357],{"class":213,"line":260},[211,1358,475],{"emptyLinePlaceholder":474},[211,1360,1361,1365,1368,1371,1374,1377,1380,1383,1386,1389],{"class":213,"line":295},[211,1362,1364],{"class":1363},"s7zQu","import",[211,1366,1367],{"class":217}," {",[211,1369,1370],{"class":279}," readFileSync",[211,1372,1373],{"class":217},",",[211,1375,1376],{"class":279}," readdirSync",[211,1378,1379],{"class":217}," }",[211,1381,1382],{"class":1363}," from",[211,1384,1385],{"class":217}," '",[211,1387,1388],{"class":275},"fs",[211,1390,1391],{"class":217},"'\n",[211,1393,1394,1396,1398,1401,1403,1406,1408,1410,1412,1415],{"class":213,"line":315},[211,1395,1364],{"class":1363},[211,1397,1367],{"class":217},[211,1399,1400],{"class":279}," join",[211,1402,1373],{"class":217},[211,1404,1405],{"class":279}," relative",[211,1407,1379],{"class":217},[211,1409,1382],{"class":1363},[211,1411,1385],{"class":217},[211,1413,1414],{"class":275},"path",[211,1416,1391],{"class":217},[211,1418,1419,1421,1423,1426,1428,1430,1432,1435],{"class":213,"line":321},[211,1420,1364],{"class":1363},[211,1422,1367],{"class":217},[211,1424,1425],{"class":279}," fileURLToPath",[211,1427,1379],{"class":217},[211,1429,1382],{"class":1363},[211,1431,1385],{"class":217},[211,1433,1434],{"class":275},"url",[211,1436,1391],{"class":217},[211,1438,1439,1441,1443,1446,1448,1450,1452,1455],{"class":213,"line":498},[211,1440,1364],{"class":1363},[211,1442,1367],{"class":217},[211,1444,1445],{"class":279}," Storage",[211,1447,1379],{"class":217},[211,1449,1382],{"class":1363},[211,1451,1385],{"class":217},[211,1453,1454],{"class":275},"@google-cloud/storage",[211,1456,1391],{"class":217},[211,1458,1459],{"class":213,"line":504},[211,1460,475],{"emptyLinePlaceholder":474},[211,1462,1463,1466,1469,1472,1475,1478,1481,1484,1486,1489,1491,1493,1495,1498,1500,1503,1505],{"class":213,"line":509},[211,1464,1465],{"class":227},"const",[211,1467,1468],{"class":279}," __dirname ",[211,1470,1471],{"class":217},"=",[211,1473,1425],{"class":1474},"s2Zo4",[211,1476,1477],{"class":279},"(",[211,1479,1480],{"class":217},"new",[211,1482,1483],{"class":1474}," URL",[211,1485,1477],{"class":279},[211,1487,1488],{"class":217},"'",[211,1490,288],{"class":275},[211,1492,1488],{"class":217},[211,1494,1373],{"class":217},[211,1496,1497],{"class":1363}," import",[211,1499,288],{"class":217},[211,1501,1502],{"class":279},"meta",[211,1504,288],{"class":217},[211,1506,1507],{"class":279},"url))\n",[211,1509,1510,1512,1515,1517,1519,1522,1524,1526,1529,1531],{"class":213,"line":515},[211,1511,1465],{"class":227},[211,1513,1514],{"class":279}," ROOT ",[211,1516,1471],{"class":217},[211,1518,1400],{"class":1474},[211,1520,1521],{"class":279},"(__dirname",[211,1523,1373],{"class":217},[211,1525,1385],{"class":217},[211,1527,1528],{"class":275},"../..",[211,1530,1488],{"class":217},[211,1532,501],{"class":279},[211,1534,1536,1538,1541,1543,1545,1548,1550,1552,1555,1557],{"class":213,"line":1535},12,[211,1537,1465],{"class":227},[211,1539,1540],{"class":279}," CONTENT_DIR ",[211,1542,1471],{"class":217},[211,1544,1400],{"class":1474},[211,1546,1547],{"class":279},"(ROOT",[211,1549,1373],{"class":217},[211,1551,1385],{"class":217},[211,1553,1554],{"class":275},"content",[211,1556,1488],{"class":217},[211,1558,501],{"class":279},[211,1560,1562],{"class":213,"line":1561},13,[211,1563,475],{"emptyLinePlaceholder":474},[211,1565,1567,1570,1573,1576,1579,1582],{"class":213,"line":1566},14,[211,1568,1569],{"class":227},"function",[211,1571,1572],{"class":1474}," toPlainText",[211,1574,1575],{"class":217}," (",[211,1577,903],{"class":1578},"sHdIc",[211,1580,1581],{"class":217},")",[211,1583,237],{"class":217},[211,1585,1587,1590],{"class":213,"line":1586},15,[211,1588,1589],{"class":1363},"  return",[211,1591,1592],{"class":279}," text\n",[211,1594,1596,1599,1602,1605,1608,1611,1614,1617,1620,1622,1624,1627,1629,1631,1634,1636],{"class":213,"line":1595},16,[211,1597,1598],{"class":217},"    .",[211,1600,1601],{"class":1474},"replace",[211,1603,1477],{"class":1604},"swJcz",[211,1606,1607],{"class":217},"/",[211,1609,1610],{"class":279},"\\*\\*",[211,1612,1613],{"class":217},"([^",[211,1615,1616],{"class":275},"*",[211,1618,1619],{"class":217},"]+)",[211,1621,1610],{"class":279},[211,1623,1607],{"class":217},[211,1625,1626],{"class":253},"g",[211,1628,1373],{"class":217},[211,1630,1385],{"class":217},[211,1632,1633],{"class":275},"$1",[211,1635,1488],{"class":217},[211,1637,501],{"class":1604},[211,1639,1641,1643,1645,1647,1649,1652,1654,1656,1658,1660,1662,1664,1666,1668,1670,1672],{"class":213,"line":1640},17,[211,1642,1598],{"class":217},[211,1644,1601],{"class":1474},[211,1646,1477],{"class":1604},[211,1648,1607],{"class":217},[211,1650,1651],{"class":279},"\\*",[211,1653,1613],{"class":217},[211,1655,1616],{"class":275},[211,1657,1619],{"class":217},[211,1659,1651],{"class":279},[211,1661,1607],{"class":217},[211,1663,1626],{"class":253},[211,1665,1373],{"class":217},[211,1667,1385],{"class":217},[211,1669,1633],{"class":275},[211,1671,1488],{"class":217},[211,1673,501],{"class":1604},[211,1675,1677,1679,1681,1683,1685,1688,1691,1694,1697,1700,1703,1705,1708],{"class":213,"line":1676},18,[211,1678,1598],{"class":217},[211,1680,1601],{"class":1474},[211,1682,1477],{"class":1604},[211,1684,1607],{"class":217},[211,1686,1687],{"class":1363},"^",[211,1689,1690],{"class":275},"#",[211,1692,1693],{"class":217},"+",[211,1695,1696],{"class":275},"\\s",[211,1698,1699],{"class":217},"+/",[211,1701,1702],{"class":253},"gm",[211,1704,1373],{"class":217},[211,1706,1707],{"class":217}," ''",[211,1709,501],{"class":1604},[211,1711,1713,1715,1717,1719,1721,1724,1726,1729,1731,1734,1737,1739,1742,1745,1747,1749,1751,1753,1755,1757],{"class":213,"line":1712},19,[211,1714,1598],{"class":217},[211,1716,1601],{"class":1474},[211,1718,1477],{"class":1604},[211,1720,1607],{"class":217},[211,1722,1723],{"class":279},"\\[",[211,1725,1613],{"class":217},[211,1727,1728],{"class":279},"\\]",[211,1730,1619],{"class":217},[211,1732,1733],{"class":279},"\\]\\(",[211,1735,1736],{"class":217},"[^",[211,1738,1581],{"class":275},[211,1740,1741],{"class":217},"]+",[211,1743,1744],{"class":279},"\\)",[211,1746,1607],{"class":217},[211,1748,1626],{"class":253},[211,1750,1373],{"class":217},[211,1752,1385],{"class":217},[211,1754,1633],{"class":275},[211,1756,1488],{"class":217},[211,1758,501],{"class":1604},[211,1760,1762,1764,1766,1768,1770,1773,1775,1777,1779,1781,1783,1785,1787,1789,1791,1793],{"class":213,"line":1761},20,[211,1763,1598],{"class":217},[211,1765,1601],{"class":1474},[211,1767,1477],{"class":1604},[211,1769,1607],{"class":217},[211,1771,1772],{"class":275},"`",[211,1774,1613],{"class":217},[211,1776,1772],{"class":275},[211,1778,1619],{"class":217},[211,1780,1772],{"class":275},[211,1782,1607],{"class":217},[211,1784,1626],{"class":253},[211,1786,1373],{"class":217},[211,1788,1385],{"class":217},[211,1790,1633],{"class":275},[211,1792,1488],{"class":217},[211,1794,501],{"class":1604},[211,1796,1798,1800,1803],{"class":213,"line":1797},21,[211,1799,1598],{"class":217},[211,1801,1802],{"class":1474},"trim",[211,1804,1805],{"class":1604},"()\n",[211,1807,1809],{"class":213,"line":1808},22,[211,1810,324],{"class":217},[211,1812,1814],{"class":213,"line":1813},23,[211,1815,475],{"emptyLinePlaceholder":474},[211,1817,1819,1821,1824,1826,1828,1830,1833,1835],{"class":213,"line":1818},24,[211,1820,1569],{"class":227},[211,1822,1823],{"class":1474}," processMarkdown",[211,1825,1575],{"class":217},[211,1827,903],{"class":1578},[211,1829,1373],{"class":217},[211,1831,1832],{"class":1578}," relPath",[211,1834,1581],{"class":217},[211,1836,237],{"class":217},[211,1838,1840,1843,1846,1849,1851,1853,1855,1857,1859,1862,1864,1866,1869,1872,1874,1877,1879,1881,1883,1885,1887,1889,1891,1893,1896,1898,1900,1902,1904,1906,1908],{"class":213,"line":1839},25,[211,1841,1842],{"class":227},"  const",[211,1844,1845],{"class":279}," sourcePath",[211,1847,1848],{"class":217}," =",[211,1850,1832],{"class":279},[211,1852,288],{"class":217},[211,1854,1601],{"class":1474},[211,1856,1477],{"class":1604},[211,1858,1607],{"class":217},[211,1860,1861],{"class":279},"\\.",[211,1863,1477],{"class":217},[211,1865,1221],{"class":275},[211,1867,1868],{"class":217},"|",[211,1870,1871],{"class":275},"yml",[211,1873,1581],{"class":217},[211,1875,1876],{"class":1363},"$",[211,1878,1607],{"class":217},[211,1880,1373],{"class":217},[211,1882,1707],{"class":217},[211,1884,1581],{"class":1604},[211,1886,288],{"class":217},[211,1888,1601],{"class":1474},[211,1890,1477],{"class":1604},[211,1892,1607],{"class":217},[211,1894,1895],{"class":279},"\\\\",[211,1897,1607],{"class":217},[211,1899,1626],{"class":253},[211,1901,1373],{"class":217},[211,1903,1385],{"class":217},[211,1905,1607],{"class":275},[211,1907,1488],{"class":217},[211,1909,501],{"class":1604},[211,1911,1913,1915,1918,1920,1923,1926,1929,1932],{"class":213,"line":1912},26,[211,1914,1842],{"class":227},[211,1916,1917],{"class":279}," sourceUrl",[211,1919,1848],{"class":217},[211,1921,1922],{"class":217}," `",[211,1924,1925],{"class":275},"SOURCE_URL: /",[211,1927,1928],{"class":217},"${",[211,1930,1931],{"class":279},"sourcePath",[211,1933,1934],{"class":217},"}`\n",[211,1936,1938,1940,1943,1945,1948,1950,1953,1955,1957,1959,1962,1965,1968,1971,1974,1977,1979,1982,1984,1986],{"class":213,"line":1937},27,[211,1939,1842],{"class":227},[211,1941,1942],{"class":279}," match",[211,1944,1848],{"class":217},[211,1946,1947],{"class":279}," text",[211,1949,288],{"class":217},[211,1951,1952],{"class":1474},"match",[211,1954,1477],{"class":1604},[211,1956,1607],{"class":217},[211,1958,1687],{"class":1363},[211,1960,1961],{"class":275},"---\\n",[211,1963,1964],{"class":217},"([",[211,1966,1967],{"class":275},"\\s\\S",[211,1969,1970],{"class":217},"]*?)",[211,1972,1973],{"class":275},"\\n---\\n",[211,1975,1976],{"class":217},"?([",[211,1978,1967],{"class":275},[211,1980,1981],{"class":217},"]*)",[211,1983,1876],{"class":1363},[211,1985,1607],{"class":217},[211,1987,501],{"class":1604},[211,1989,1991,1994,1996,1999,2001,2004,2007,2010,2013,2016,2019,2021,2024,2027,2030,2032,2034],{"class":213,"line":1990},28,[211,1992,1993],{"class":1363},"  if",[211,1995,1575],{"class":1604},[211,1997,1998],{"class":217},"!",[211,2000,1952],{"class":279},[211,2002,2003],{"class":1604},") ",[211,2005,2006],{"class":1363},"return",[211,2008,2009],{"class":217}," `${",[211,2011,2012],{"class":279},"sourceUrl",[211,2014,2015],{"class":217},"}",[211,2017,2018],{"class":279},"\\n\\n",[211,2020,1928],{"class":217},[211,2022,2023],{"class":1474},"toPlainText",[211,2025,2026],{"class":279},"(text)",[211,2028,2029],{"class":217},"}`",[211,2031,288],{"class":217},[211,2033,1802],{"class":1474},[211,2035,1805],{"class":1604},[211,2037,2039,2041,2044,2046,2048,2051,2054,2057,2059,2062,2064,2066,2069,2071,2073,2075,2078,2080,2083,2086,2089,2091,2093,2096,2098,2101,2103,2105,2107,2109,2112,2115,2117,2119,2122,2124,2126,2128,2130,2133,2135,2138,2140,2142,2144,2146],{"class":213,"line":2038},29,[211,2040,1842],{"class":227},[211,2042,2043],{"class":279}," meta",[211,2045,1848],{"class":217},[211,2047,1942],{"class":279},[211,2049,2050],{"class":1604},"[",[211,2052,2053],{"class":253},"1",[211,2055,2056],{"class":1604},"]",[211,2058,288],{"class":217},[211,2060,2061],{"class":1474},"split",[211,2063,1477],{"class":1604},[211,2065,1488],{"class":217},[211,2067,2068],{"class":279},"\\n",[211,2070,1488],{"class":217},[211,2072,1581],{"class":1604},[211,2074,288],{"class":217},[211,2076,2077],{"class":1474},"map",[211,2079,1477],{"class":1604},[211,2081,2082],{"class":1578},"l",[211,2084,2085],{"class":227}," =>",[211,2087,2088],{"class":279}," l",[211,2090,288],{"class":217},[211,2092,1802],{"class":1474},[211,2094,2095],{"class":1604},"())",[211,2097,288],{"class":217},[211,2099,2100],{"class":1474},"filter",[211,2102,1477],{"class":1604},[211,2104,2082],{"class":1578},[211,2106,2085],{"class":227},[211,2108,2088],{"class":279},[211,2110,2111],{"class":217}," &&",[211,2113,2114],{"class":217}," !",[211,2116,2082],{"class":279},[211,2118,288],{"class":217},[211,2120,2121],{"class":1474},"startsWith",[211,2123,1477],{"class":1604},[211,2125,1488],{"class":217},[211,2127,1690],{"class":275},[211,2129,1488],{"class":217},[211,2131,2132],{"class":1604},"))",[211,2134,288],{"class":217},[211,2136,2137],{"class":1474},"join",[211,2139,1477],{"class":1604},[211,2141,1488],{"class":217},[211,2143,2068],{"class":279},[211,2145,1488],{"class":217},[211,2147,501],{"class":1604},[211,2149,2151,2153,2155,2157,2159,2161,2163,2165,2167,2169,2171,2173,2176,2179,2182,2184,2186,2188],{"class":213,"line":2150},30,[211,2152,1589],{"class":1363},[211,2154,2009],{"class":217},[211,2156,2012],{"class":279},[211,2158,2015],{"class":217},[211,2160,2068],{"class":279},[211,2162,1928],{"class":217},[211,2164,1502],{"class":279},[211,2166,2015],{"class":217},[211,2168,2018],{"class":279},[211,2170,1928],{"class":217},[211,2172,2023],{"class":1474},[211,2174,2175],{"class":279},"(match[",[211,2177,2178],{"class":253},"2",[211,2180,2181],{"class":279},"])",[211,2183,2029],{"class":217},[211,2185,288],{"class":217},[211,2187,1802],{"class":1474},[211,2189,1805],{"class":1604},[211,2191,2193],{"class":213,"line":2192},31,[211,2194,324],{"class":217},[211,2196,2198],{"class":213,"line":2197},32,[211,2199,475],{"emptyLinePlaceholder":474},[211,2201,2203,2205,2208,2210,2213,2215,2218,2220,2223,2225],{"class":213,"line":2202},33,[211,2204,1569],{"class":227},[211,2206,2207],{"class":1474}," getFiles",[211,2209,1575],{"class":217},[211,2211,2212],{"class":1578},"dir",[211,2214,1373],{"class":217},[211,2216,2217],{"class":1578}," base",[211,2219,1848],{"class":217},[211,2221,2222],{"class":279}," dir",[211,2224,1581],{"class":217},[211,2226,237],{"class":217},[211,2228,2230,2232,2235,2237],{"class":213,"line":2229},34,[211,2231,1842],{"class":227},[211,2233,2234],{"class":279}," files",[211,2236,1848],{"class":217},[211,2238,2239],{"class":1604}," []\n",[211,2241,2243,2246,2248,2250,2253,2256,2258,2260,2262,2264,2266,2269,2271,2275,2277,2280],{"class":213,"line":2242},35,[211,2244,2245],{"class":1363},"  for",[211,2247,1575],{"class":1604},[211,2249,1465],{"class":227},[211,2251,2252],{"class":279}," ent",[211,2254,2255],{"class":217}," of",[211,2257,1376],{"class":1474},[211,2259,1477],{"class":1604},[211,2261,2212],{"class":279},[211,2263,1373],{"class":217},[211,2265,1367],{"class":217},[211,2267,2268],{"class":1604}," withFileTypes",[211,2270,234],{"class":217},[211,2272,2274],{"class":2273},"sfNiH"," true",[211,2276,1379],{"class":217},[211,2278,2279],{"class":1604},")) ",[211,2281,218],{"class":217},[211,2283,2285,2288,2291,2293,2295,2297,2299,2301,2303,2305,2308],{"class":213,"line":2284},36,[211,2286,2287],{"class":227},"    const",[211,2289,2290],{"class":279}," full",[211,2292,1848],{"class":217},[211,2294,1400],{"class":1474},[211,2296,1477],{"class":1604},[211,2298,2212],{"class":279},[211,2300,1373],{"class":217},[211,2302,2252],{"class":279},[211,2304,288],{"class":217},[211,2306,2307],{"class":279},"name",[211,2309,501],{"class":1604},[211,2311,2313,2315,2318,2320,2322,2324,2327,2329,2331],{"class":213,"line":2312},37,[211,2314,2287],{"class":227},[211,2316,2317],{"class":279}," rel",[211,2319,1848],{"class":217},[211,2321,1405],{"class":1474},[211,2323,1477],{"class":1604},[211,2325,2326],{"class":279},"base",[211,2328,1373],{"class":217},[211,2330,2290],{"class":279},[211,2332,501],{"class":1604},[211,2334,2336,2339,2341,2344,2346,2349,2352,2355,2357,2359,2361,2363,2365,2367,2369,2371,2373,2375,2377,2379,2381,2383,2385,2388,2390,2393,2395,2397],{"class":213,"line":2335},38,[211,2337,2338],{"class":1363},"    if",[211,2340,1575],{"class":1604},[211,2342,2343],{"class":279},"ent",[211,2345,288],{"class":217},[211,2347,2348],{"class":1474},"isDirectory",[211,2350,2351],{"class":1604},"() ",[211,2353,2354],{"class":217},"&&",[211,2356,2114],{"class":217},[211,2358,2343],{"class":279},[211,2360,288],{"class":217},[211,2362,2307],{"class":279},[211,2364,288],{"class":217},[211,2366,2121],{"class":1474},[211,2368,1477],{"class":1604},[211,2370,1488],{"class":217},[211,2372,288],{"class":275},[211,2374,1488],{"class":217},[211,2376,2003],{"class":1604},[211,2378,2354],{"class":217},[211,2380,2252],{"class":279},[211,2382,288],{"class":217},[211,2384,2307],{"class":279},[211,2386,2387],{"class":217}," !==",[211,2389,1385],{"class":217},[211,2391,2392],{"class":275},"node_modules",[211,2394,1488],{"class":217},[211,2396,2003],{"class":1604},[211,2398,218],{"class":217},[211,2400,2402,2405,2407,2410,2412,2415,2418,2420,2423,2425,2427],{"class":213,"line":2401},39,[211,2403,2404],{"class":279},"      files",[211,2406,288],{"class":217},[211,2408,2409],{"class":1474},"push",[211,2411,1477],{"class":1604},[211,2413,2414],{"class":217},"...",[211,2416,2417],{"class":1474},"getFiles",[211,2419,1477],{"class":1604},[211,2421,2422],{"class":279},"full",[211,2424,1373],{"class":217},[211,2426,2217],{"class":279},[211,2428,2429],{"class":1604},"))\n",[211,2431,2433,2436,2439,2442,2444,2446,2448,2451,2453,2455,2457,2459,2461,2463,2465,2468,2470,2472,2474,2476,2478,2481,2483,2485,2487,2489,2491,2493,2495,2497,2499,2502],{"class":213,"line":2432},40,[211,2434,2435],{"class":217},"    }",[211,2437,2438],{"class":1363}," else",[211,2440,2441],{"class":1363}," if",[211,2443,1575],{"class":1604},[211,2445,2343],{"class":279},[211,2447,288],{"class":217},[211,2449,2450],{"class":1474},"isFile",[211,2452,2351],{"class":1604},[211,2454,2354],{"class":217},[211,2456,1575],{"class":1604},[211,2458,2343],{"class":279},[211,2460,288],{"class":217},[211,2462,2307],{"class":279},[211,2464,288],{"class":217},[211,2466,2467],{"class":1474},"endsWith",[211,2469,1477],{"class":1604},[211,2471,1488],{"class":217},[211,2473,1307],{"class":275},[211,2475,1488],{"class":217},[211,2477,2003],{"class":1604},[211,2479,2480],{"class":217},"||",[211,2482,2252],{"class":279},[211,2484,288],{"class":217},[211,2486,2307],{"class":279},[211,2488,288],{"class":217},[211,2490,2467],{"class":1474},[211,2492,1477],{"class":1604},[211,2494,1488],{"class":217},[211,2496,1311],{"class":275},[211,2498,1488],{"class":217},[211,2500,2501],{"class":1604},"))) ",[211,2503,218],{"class":217},[211,2505,2507,2509,2511,2513,2515,2517,2520,2522,2524,2526,2528,2530],{"class":213,"line":2506},41,[211,2508,2404],{"class":279},[211,2510,288],{"class":217},[211,2512,2409],{"class":1474},[211,2514,1477],{"class":1604},[211,2516,1001],{"class":217},[211,2518,2519],{"class":1604}," path",[211,2521,234],{"class":217},[211,2523,2290],{"class":279},[211,2525,1373],{"class":217},[211,2527,2317],{"class":279},[211,2529,1379],{"class":217},[211,2531,501],{"class":1604},[211,2533,2535],{"class":213,"line":2534},42,[211,2536,2537],{"class":217},"    }\n",[211,2539,2541],{"class":213,"line":2540},43,[211,2542,318],{"class":217},[211,2544,2546,2548],{"class":213,"line":2545},44,[211,2547,1589],{"class":1363},[211,2549,2550],{"class":279}," files\n",[211,2552,2554],{"class":213,"line":2553},45,[211,2555,324],{"class":217},[211,2557,2559],{"class":213,"line":2558},46,[211,2560,475],{"emptyLinePlaceholder":474},[211,2562,2564,2567,2570,2573,2576],{"class":213,"line":2563},47,[211,2565,2566],{"class":227},"async",[211,2568,2569],{"class":227}," function",[211,2571,2572],{"class":1474}," main",[211,2574,2575],{"class":217}," ()",[211,2577,237],{"class":217},[211,2579,2581,2583,2586,2588,2591,2593,2596,2598],{"class":213,"line":2580},48,[211,2582,1842],{"class":227},[211,2584,2585],{"class":279}," bucketName",[211,2587,1848],{"class":217},[211,2589,2590],{"class":279}," process",[211,2592,288],{"class":217},[211,2594,2595],{"class":279},"env",[211,2597,288],{"class":217},[211,2599,2600],{"class":279},"ASK_NICO_GCS_BUCKET\n",[211,2602,2604,2606,2609,2611,2613,2615,2617,2619],{"class":213,"line":2603},49,[211,2605,1842],{"class":227},[211,2607,2608],{"class":279}," projectId",[211,2610,1848],{"class":217},[211,2612,2590],{"class":279},[211,2614,288],{"class":217},[211,2616,2595],{"class":279},[211,2618,288],{"class":217},[211,2620,2621],{"class":279},"GCP_PROJECT\n",[211,2623,2625,2627,2629,2631,2634,2637,2639,2642,2644],{"class":213,"line":2624},50,[211,2626,1993],{"class":1363},[211,2628,1575],{"class":1604},[211,2630,1998],{"class":217},[211,2632,2633],{"class":279},"bucketName",[211,2635,2636],{"class":217}," ||",[211,2638,2114],{"class":217},[211,2640,2641],{"class":279},"projectId",[211,2643,2003],{"class":1604},[211,2645,218],{"class":217},[211,2647,2649,2652,2654,2656,2658,2660,2663,2665],{"class":213,"line":2648},51,[211,2650,2651],{"class":279},"    console",[211,2653,288],{"class":217},[211,2655,228],{"class":1474},[211,2657,1477],{"class":1604},[211,2659,1488],{"class":217},[211,2661,2662],{"class":275},"Set GCP_PROJECT and ASK_NICO_GCS_BUCKET",[211,2664,1488],{"class":217},[211,2666,501],{"class":1604},[211,2668,2670,2673,2675,2678,2680,2682],{"class":213,"line":2669},52,[211,2671,2672],{"class":279},"    process",[211,2674,288],{"class":217},[211,2676,2677],{"class":1474},"exit",[211,2679,1477],{"class":1604},[211,2681,2053],{"class":253},[211,2683,501],{"class":1604},[211,2685,2687],{"class":213,"line":2686},53,[211,2688,318],{"class":217},[211,2690,2692,2694,2697,2699,2702,2704,2706,2708,2710,2712,2714,2716,2719,2721,2723],{"class":213,"line":2691},54,[211,2693,1842],{"class":227},[211,2695,2696],{"class":279}," bucket",[211,2698,1848],{"class":217},[211,2700,2701],{"class":217}," new",[211,2703,1445],{"class":1474},[211,2705,1477],{"class":1604},[211,2707,1001],{"class":217},[211,2709,2608],{"class":279},[211,2711,1379],{"class":217},[211,2713,1581],{"class":1604},[211,2715,288],{"class":217},[211,2717,2718],{"class":1474},"bucket",[211,2720,1477],{"class":1604},[211,2722,2633],{"class":279},[211,2724,501],{"class":1604},[211,2726,2728,2730,2732,2734,2736,2738,2741],{"class":213,"line":2727},55,[211,2729,1842],{"class":227},[211,2731,2234],{"class":279},[211,2733,1848],{"class":217},[211,2735,2207],{"class":1474},[211,2737,1477],{"class":1604},[211,2739,2740],{"class":279},"CONTENT_DIR",[211,2742,501],{"class":1604},[211,2744,2746,2748,2750,2752,2754,2756,2758,2761,2763,2765,2767,2769,2771,2773],{"class":213,"line":2745},56,[211,2747,2245],{"class":1363},[211,2749,1575],{"class":1604},[211,2751,1465],{"class":227},[211,2753,1367],{"class":217},[211,2755,2519],{"class":1604},[211,2757,234],{"class":217},[211,2759,2760],{"class":279}," fp",[211,2762,1373],{"class":217},[211,2764,2317],{"class":279},[211,2766,1379],{"class":217},[211,2768,2255],{"class":217},[211,2770,2234],{"class":279},[211,2772,2003],{"class":1604},[211,2774,218],{"class":217},[211,2776,2778,2780,2783,2785,2787,2789,2792,2794,2797,2799,2801,2804,2806,2808,2810,2812],{"class":213,"line":2777},57,[211,2779,2287],{"class":227},[211,2781,2782],{"class":279}," plain",[211,2784,1848],{"class":217},[211,2786,1823],{"class":1474},[211,2788,1477],{"class":1604},[211,2790,2791],{"class":1474},"readFileSync",[211,2793,1477],{"class":1604},[211,2795,2796],{"class":279},"fp",[211,2798,1373],{"class":217},[211,2800,1385],{"class":217},[211,2802,2803],{"class":275},"utf-8",[211,2805,1488],{"class":217},[211,2807,1581],{"class":1604},[211,2809,1373],{"class":217},[211,2811,2317],{"class":279},[211,2813,501],{"class":1604},[211,2815,2817,2819,2821,2823,2826,2828],{"class":213,"line":2816},58,[211,2818,2338],{"class":1363},[211,2820,1575],{"class":1604},[211,2822,1998],{"class":217},[211,2824,2825],{"class":279},"plain",[211,2827,2003],{"class":1604},[211,2829,2830],{"class":1363},"continue\n",[211,2832,2834,2836,2839,2841,2843,2846,2848,2851,2853,2855,2857,2859,2861,2863,2865,2867,2869,2871,2873,2875,2877,2879,2881,2883,2885,2887,2889,2891,2893,2895,2897,2899,2901,2903,2905,2907,2909],{"class":213,"line":2833},59,[211,2835,2287],{"class":227},[211,2837,2838],{"class":279}," gcsName",[211,2840,1848],{"class":217},[211,2842,1922],{"class":217},[211,2844,2845],{"class":275},"ask-nico/",[211,2847,1928],{"class":217},[211,2849,2850],{"class":279},"rel",[211,2852,288],{"class":217},[211,2854,1601],{"class":1474},[211,2856,1477],{"class":279},[211,2858,1607],{"class":217},[211,2860,1861],{"class":279},[211,2862,1477],{"class":217},[211,2864,1221],{"class":275},[211,2866,1868],{"class":217},[211,2868,1871],{"class":275},[211,2870,1581],{"class":217},[211,2872,1876],{"class":1363},[211,2874,1607],{"class":217},[211,2876,1373],{"class":217},[211,2878,1385],{"class":217},[211,2880,1323],{"class":275},[211,2882,1488],{"class":217},[211,2884,1581],{"class":279},[211,2886,288],{"class":217},[211,2888,1601],{"class":1474},[211,2890,1477],{"class":279},[211,2892,1607],{"class":217},[211,2894,1895],{"class":279},[211,2896,1607],{"class":217},[211,2898,1626],{"class":253},[211,2900,1373],{"class":217},[211,2902,1385],{"class":217},[211,2904,1607],{"class":275},[211,2906,1488],{"class":217},[211,2908,1581],{"class":279},[211,2910,1934],{"class":217},[211,2912,2914,2917,2919,2921,2924,2926,2929,2931,2933,2936,2938,2940,2942,2944,2947,2949,2951,2954,2956,2958],{"class":213,"line":2913},60,[211,2915,2916],{"class":1363},"    await",[211,2918,2696],{"class":279},[211,2920,288],{"class":217},[211,2922,2923],{"class":1474},"file",[211,2925,1477],{"class":1604},[211,2927,2928],{"class":279},"gcsName",[211,2930,1581],{"class":1604},[211,2932,288],{"class":217},[211,2934,2935],{"class":1474},"save",[211,2937,1477],{"class":1604},[211,2939,2825],{"class":279},[211,2941,1373],{"class":217},[211,2943,1367],{"class":217},[211,2945,2946],{"class":1604}," contentType",[211,2948,234],{"class":217},[211,2950,1385],{"class":217},[211,2952,2953],{"class":275},"text/plain",[211,2955,1488],{"class":217},[211,2957,1379],{"class":217},[211,2959,501],{"class":1604},[211,2961,2963,2965,2967,2970,2972,2974,2977,2979,2981,2983],{"class":213,"line":2962},61,[211,2964,2651],{"class":279},[211,2966,288],{"class":217},[211,2968,2969],{"class":1474},"log",[211,2971,1477],{"class":1604},[211,2973,1488],{"class":217},[211,2975,2976],{"class":275},"Uploaded",[211,2978,1488],{"class":217},[211,2980,1373],{"class":217},[211,2982,2838],{"class":279},[211,2984,501],{"class":1604},[211,2986,2988],{"class":213,"line":2987},62,[211,2989,318],{"class":217},[211,2991,2993,2996,2998,3000,3002,3004,3007,3009],{"class":213,"line":2992},63,[211,2994,2995],{"class":279},"  console",[211,2997,288],{"class":217},[211,2999,2969],{"class":1474},[211,3001,1477],{"class":1604},[211,3003,1488],{"class":217},[211,3005,3006],{"class":275},"Done",[211,3008,1488],{"class":217},[211,3010,501],{"class":1604},[211,3012,3014],{"class":213,"line":3013},64,[211,3015,324],{"class":217},[211,3017,3019],{"class":213,"line":3018},65,[211,3020,475],{"emptyLinePlaceholder":474},[211,3022,3024,3027,3030,3032,3035,3037,3040,3042,3044,3047,3049,3051,3053,3055,3057,3060,3062,3064,3066,3068,3070,3072,3074],{"class":213,"line":3023},66,[211,3025,3026],{"class":1474},"main",[211,3028,3029],{"class":279},"()",[211,3031,288],{"class":217},[211,3033,3034],{"class":1474},"catch",[211,3036,1477],{"class":279},[211,3038,3039],{"class":1578},"e",[211,3041,2085],{"class":227},[211,3043,1367],{"class":217},[211,3045,3046],{"class":279}," console",[211,3048,288],{"class":217},[211,3050,228],{"class":1474},[211,3052,1477],{"class":1604},[211,3054,3039],{"class":279},[211,3056,1581],{"class":1604},[211,3058,3059],{"class":217},";",[211,3061,2590],{"class":279},[211,3063,288],{"class":217},[211,3065,2677],{"class":1474},[211,3067,1477],{"class":1604},[211,3069,2053],{"class":253},[211,3071,2003],{"class":1604},[211,3073,2015],{"class":217},[211,3075,501],{"class":279},[163,3077,3078,3079,3082,3083,3086,3087,288],{},"Install the dependency (",[172,3080,3081],{},"pnpm add @google-cloud/storage","), authenticate (",[172,3084,3085],{},"gcloud auth application-default login","), then run it with your project number and bucket name. The CI/CD section shows how to wire this into a GitHub Action so the bucket stays in sync on every push to ",[172,3088,3026],{},[163,3090,3091],{},[3092,3093],"img",{"alt":3094,"className":3095,"src":3097},"Screenshot of the GCS bucket in Cloud Storage.",[3096],"rounded-lg","https://storage.googleapis.com/nico-westerdale-images/blog/ask-nico-turning-my-static-blog-into-a-rag-powered-llm-chat/vertext-ai-storage-bucket.png",[728,3099,3101],{"id":3100},"_2-create-the-vertex-ai-search-app","2. Create the Vertex AI Search App",[163,3103,3104,3105,3110],{},"GCP is constantly updating its AI infrastructure and UX, so a lot of the guides out there are out of date. Even the names keep changing; Gemini used to be called Bard, Vertex used to be called AI Platform, and the gen-app-builder console has been reorganized more than once. The hardest part was often finding where to go, but once there the concepts are straightforward. I went to ",[167,3106,3109],{"href":3107,"rel":3108},"https://console.cloud.google.com/gen-app-builder",[403],"AI Applications > Apps"," in the GCP console and created a new application of this type: \"Custom search (general)\".",[163,3112,3113],{},[3092,3114],{"alt":3115,"className":3116,"src":3117},"Screenshot of the Vertex AI Search create application screen showing the Custom search (general) option.",[3096],"https://storage.googleapis.com/nico-westerdale-images/blog/ask-nico-turning-my-static-blog-into-a-rag-powered-llm-chat/vertext-ai-create-application.png",[163,3119,3120,3121,3124,3125,3128,3129,3132,3133,3136,3137,3139],{},"In the data source configuration I selected Cloud Storage and pointed it at my bucket with the path ",[172,3122,3123],{},"gs://your-bucket-name/ask-nico/**",". The recursive wildcard ensures all subfolders (",[172,3126,3127],{},"blog/",", ",[172,3130,3131],{},"gallery/",", etc.) are indexed. For synchronization frequency I chose ",[197,3134,3135],{},"One time","; the CI/CD pipeline triggers re-imports via the Discovery Engine API whenever content changes on ",[172,3138,3026],{},", so I didn't need periodic or streaming sync.",[163,3141,3142],{},[3092,3143],{"alt":3144,"className":3145,"src":3146},"Screenshot of the Vertex AI Search data store selection screen.",[3096],"https://storage.googleapis.com/nico-westerdale-images/blog/ask-nico-turning-my-static-blog-into-a-rag-powered-llm-chat/vertext-ai-select-data-store.png",[163,3148,3149,3150,3153,3154,3156],{},"Clicking through, I selected \"Unstructured\" data since the content is freeform text. Under the import options I chose ",[197,3151,3152],{},"Documents"," (unstructured files like TXT, HTML, PDF). I skipped \"Documents with Metadata (RAG)\" since that expects JSONL linking metadata to documents; my ingestion script produces plain ",[172,3155,1323],{}," files. I could have structured the files and mapped the metadata more exactly, but from what I found it wasn't needed.",[163,3158,3159,3160,3165,3166,3171,3172,3175,3176,3179,3180,3183,3184,3187,3188,3191,3192,3194,3195,288],{},"I enabled the APIs I needed: ",[167,3161,3164],{"href":3162,"rel":3163},"https://console.cloud.google.com/apis/library/discoveryengine.googleapis.com",[403],"Discovery Engine"," for indexing and search, and ",[167,3167,3170],{"href":3168,"rel":3169},"https://console.cloud.google.com/apis/library/aiplatform.googleapis.com",[403],"Vertex AI"," for the Gemini model that powers the Answer API. Once the app is created, it generates both a data store ID and an engine ID. Here is how they interact: the ",[197,3173,3174],{},"data store"," holds the indexed content and is what the CI/CD import API writes to. The ",[197,3177,3178],{},"engine"," wraps the data store and exposes the Answer API (retrieval plus grounded generation in one call). For chat you want the engine ID; for triggering re-imports you need the data store ID. You may need both. The data store ID is shown in the app's data store section; the engine ID lives in the app details (look for \"Engine\" or \"Engine ID\"). If you hit ",[172,3181,3182],{},"NOT_FOUND"," errors with the data store path, switch to the engine ID. One gotcha: Discovery Engine expects your project ",[197,3185,3186],{},"number"," (e.g. ",[172,3189,3190],{},"123456789012","), not the project ID. You'll need that for the API path. For Gemini, the location must be regional (e.g. ",[172,3193,1300],{},"); it doesn't support ",[172,3196,3197],{},"global",[163,3199,3200,3201,3204],{},"Add these to ",[172,3202,3203],{},".env"," for local dev and deployment:",[202,3206,3210],{"className":3207,"code":3208,"language":3209,"meta":207,"style":207},"language-bash shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","GCP_PROJECT=your-project-id              # project ID (string); required for fallback Gemini path\nASK_NICO_PROJECT_NUMBER=123456789012     # project number (numeric); Discovery Engine and CI ingest use this\nASK_NICO_DATA_STORE=your-datastore-id    # for import; use ASK_NICO_ENGINE_ID for chat if DataStore returns NOT_FOUND\nASK_NICO_ENGINE_ID=your-engine-id        # preferred for Answer API\nASK_NICO_GEMINI_LOCATION=us-central1\n# Optional: ASK_NICO_GEMINI_MODEL=gemini-3.1-flash-lite-preview (default)\n","bash",[172,3211,3212,3225,3237,3250,3263,3273],{"__ignoreMap":207},[211,3213,3214,3217,3219,3222],{"class":213,"line":214},[211,3215,3216],{"class":279},"GCP_PROJECT",[211,3218,1471],{"class":217},[211,3220,3221],{"class":275},"your-project-id",[211,3223,3224],{"class":1343},"              # project ID (string); required for fallback Gemini path\n",[211,3226,3227,3230,3232,3234],{"class":213,"line":221},[211,3228,3229],{"class":279},"ASK_NICO_PROJECT_NUMBER",[211,3231,1471],{"class":217},[211,3233,3190],{"class":275},[211,3235,3236],{"class":1343},"     # project number (numeric); Discovery Engine and CI ingest use this\n",[211,3238,3239,3242,3244,3247],{"class":213,"line":240},[211,3240,3241],{"class":279},"ASK_NICO_DATA_STORE",[211,3243,1471],{"class":217},[211,3245,3246],{"class":275},"your-datastore-id",[211,3248,3249],{"class":1343},"    # for import; use ASK_NICO_ENGINE_ID for chat if DataStore returns NOT_FOUND\n",[211,3251,3252,3255,3257,3260],{"class":213,"line":260},[211,3253,3254],{"class":279},"ASK_NICO_ENGINE_ID",[211,3256,1471],{"class":217},[211,3258,3259],{"class":275},"your-engine-id",[211,3261,3262],{"class":1343},"        # preferred for Answer API\n",[211,3264,3265,3268,3270],{"class":213,"line":295},[211,3266,3267],{"class":279},"ASK_NICO_GEMINI_LOCATION",[211,3269,1471],{"class":217},[211,3271,3272],{"class":275},"us-central1\n",[211,3274,3275],{"class":213,"line":315},[211,3276,3277],{"class":1343},"# Optional: ASK_NICO_GEMINI_MODEL=gemini-3.1-flash-lite-preview (default)\n",[163,3279,3280,3281,3283,3284,3286,3287,3289],{},"The ingest script expects ",[172,3282,3216],{}," (project ID or number; both work for Storage). For local runs set it; in CI the workflow maps ",[172,3285,3229],{}," to ",[172,3288,3216],{}," when invoking the script.",[163,3291,3292,3293,3295,3296,3299,3300,288],{},"For local dev, run ",[172,3294,3085],{}," so the server can authenticate to GCP. For production, the server uses Application Default Credentials: either set ",[172,3297,3298],{},"GOOGLE_APPLICATION_CREDENTIALS"," to the path of a service account JSON key, or run on GCP (e.g. Cloud Run, Firebase) where the default service account is used automatically. Ensure the service account has Discovery Engine and Vertex AI access. Set the same env vars on your host (Firebase, Vercel, etc.) as in ",[172,3301,3203],{},[163,3303,3304],{},"Looking at all the \"Preview\" labels in the UX, you can see Google is scrambling to plumb their entire ecosystem together and enable seamless integration of their existing cloud assets with the Vertex stack. There's some interesting streaming ideas that are in Preview, and more data sources coming online. If you're reading this later in the year, the UX will likely be markedly different. There are also whole other sides of the Vertex stack we're not touching on here; this barely scratches the surface.",[728,3306,3308],{"id":3307},"_3-run-initial-ingest","3. Run initial ingest",[163,3310,3311],{},"Before the data store can index anything, the bucket needs content. Run the ingestion script locally once:",[202,3313,3315],{"className":872,"code":3314,"language":874,"meta":207,"style":207},"$env:GCP_PROJECT = \"your-project-number\"\n$env:ASK_NICO_GCS_BUCKET = \"your-bucket-name\"\ngcloud auth application-default login\nnode scripts/ask-nico-ingest/index.mjs\n",[172,3316,3317,3322,3327,3332],{"__ignoreMap":207},[211,3318,3319],{"class":213,"line":214},[211,3320,3321],{},"$env:GCP_PROJECT = \"your-project-number\"\n",[211,3323,3324],{"class":213,"line":221},[211,3325,3326],{},"$env:ASK_NICO_GCS_BUCKET = \"your-bucket-name\"\n",[211,3328,3329],{"class":213,"line":240},[211,3330,3331],{},"gcloud auth application-default login\n",[211,3333,3334],{"class":213,"line":260},[211,3335,3336],{},"node scripts/ask-nico-ingest/index.mjs\n",[163,3338,3339],{},"Then trigger an import from the data source settings in the Vertex AI Search console (or wait if auto-sync runs). After the first run, CI/CD keeps the bucket updated.",[728,3341,3343],{"id":3342},"_4-seed-the-data-store","4. Seed the Data Store",[163,3345,3346],{},"Once the bucket was configured and the data source pointed at it, Vertex AI Search started the initial import automatically. With the files in storage from the ingest step, I didn't need to trigger anything manually. Search returns 0 results until indexing completes; for larger datasets this can take 15–30 minutes.",[163,3348,3349],{},[3092,3350],{"alt":3351,"className":3352,"src":3353},"Screenshot of the Vertex AI Search data store after indexing.",[3096],"https://storage.googleapis.com/nico-westerdale-images/blog/ask-nico-turning-my-static-blog-into-a-rag-powered-llm-chat/vertext-ai-data-store.png",[186,3355,3357],{"id":3356},"testing-in-the-gcp-ui","Testing in the GCP UI",[163,3359,3360],{},"Before wiring up any custom UX, I verified the setup in the GCP console. I opened my Vertex AI Search app and used the built-in chat interface. I asked a question that I knew existed in my content. When I got grounded responses with citations, I knew the data store was working. So far this has all been point-and-click devops, and very straightforward to do. Google even produces some pre-built UI widgets that should seamlessly integrate with any website, and links to them right from the AI App page.",[163,3362,3363],{},[3092,3364],{"alt":3365,"className":3366,"src":3367},"Screenshot of testing the Vertex AI Search chat interface in the GCP console.",[3096],"https://storage.googleapis.com/nico-westerdale-images/blog/ask-nico-turning-my-static-blog-into-a-rag-powered-llm-chat/vertext-ai-testing-llm.png",[186,3369,3371],{"id":3370},"custom-ux-in-the-lab","Custom UX in the Lab",[163,3373,3374],{},"Instead of opting for the prebuilt widgets I wanted a more integrated seamless UX. With the backend working, the next step was a custom interface. Install the GCP client libraries:",[202,3376,3378],{"className":872,"code":3377,"language":874,"meta":207,"style":207},"pnpm add @google-cloud/discoveryengine @google-cloud/vertexai\n",[172,3379,3380],{"__ignoreMap":207},[211,3381,3382],{"class":213,"line":214},[211,3383,3377],{},[163,3385,3386,3387,3390,3391,3394],{},"I built a Vue component at ",[172,3388,3389],{},"app/components/lab/ask-nico/Index.vue"," that provides a chat UI with suggested prompts, session management, and a \"reveal-down\" animation for assistant responses. The Nuxt server route at ",[172,3392,3393],{},"server/api/lab/ask-nico/chat.post.ts"," calls the Vertex AI Answer API, passing the user's query and returning the grounded response.",[163,3396,3397],{},"The client just posts messages to the API:",[202,3399,3403],{"className":3400,"code":3401,"language":3402,"meta":207,"style":207},"language-typescript shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","// app/components/lab/ask-nico/Index.vue\nconst response = await $fetch('/api/lab/ask-nico/chat', {\n  method: 'POST',\n  body: {\n    messages: messages.value.map(m => ({ role: m.role, content: m.content })),\n    ...(sessionId.value ? { sessionId: sessionId.value } : {})\n  }\n})\n","typescript",[172,3404,3405,3410,3438,3454,3463,3526,3565,3569],{"__ignoreMap":207},[211,3406,3407],{"class":213,"line":214},[211,3408,3409],{"class":1343},"// app/components/lab/ask-nico/Index.vue\n",[211,3411,3412,3414,3417,3419,3422,3425,3427,3429,3432,3434,3436],{"class":213,"line":221},[211,3413,1465],{"class":227},[211,3415,3416],{"class":279}," response ",[211,3418,1471],{"class":217},[211,3420,3421],{"class":1363}," await",[211,3423,3424],{"class":1474}," $fetch",[211,3426,1477],{"class":279},[211,3428,1488],{"class":217},[211,3430,3431],{"class":275},"/api/lab/ask-nico/chat",[211,3433,1488],{"class":217},[211,3435,1373],{"class":217},[211,3437,237],{"class":217},[211,3439,3440,3443,3445,3447,3450,3452],{"class":213,"line":240},[211,3441,3442],{"class":1604},"  method",[211,3444,234],{"class":217},[211,3446,1385],{"class":217},[211,3448,3449],{"class":275},"POST",[211,3451,1488],{"class":217},[211,3453,257],{"class":217},[211,3455,3456,3459,3461],{"class":213,"line":260},[211,3457,3458],{"class":1604},"  body",[211,3460,234],{"class":217},[211,3462,237],{"class":217},[211,3464,3465,3468,3470,3473,3475,3478,3480,3482,3484,3487,3489,3491,3493,3496,3498,3501,3503,3506,3508,3511,3513,3515,3517,3520,3522,3524],{"class":213,"line":295},[211,3466,3467],{"class":1604},"    messages",[211,3469,234],{"class":217},[211,3471,3472],{"class":279}," messages",[211,3474,288],{"class":217},[211,3476,3477],{"class":279},"value",[211,3479,288],{"class":217},[211,3481,2077],{"class":1474},[211,3483,1477],{"class":279},[211,3485,3486],{"class":1578},"m",[211,3488,2085],{"class":227},[211,3490,1575],{"class":279},[211,3492,1001],{"class":217},[211,3494,3495],{"class":1604}," role",[211,3497,234],{"class":217},[211,3499,3500],{"class":279}," m",[211,3502,288],{"class":217},[211,3504,3505],{"class":279},"role",[211,3507,1373],{"class":217},[211,3509,3510],{"class":1604}," content",[211,3512,234],{"class":217},[211,3514,3500],{"class":279},[211,3516,288],{"class":217},[211,3518,3519],{"class":279},"content ",[211,3521,2015],{"class":217},[211,3523,2132],{"class":279},[211,3525,257],{"class":217},[211,3527,3528,3531,3534,3536,3539,3542,3544,3547,3549,3551,3553,3555,3557,3560,3563],{"class":213,"line":315},[211,3529,3530],{"class":217},"    ...",[211,3532,3533],{"class":279},"(sessionId",[211,3535,288],{"class":217},[211,3537,3538],{"class":279},"value ",[211,3540,3541],{"class":217},"?",[211,3543,1367],{"class":217},[211,3545,3546],{"class":1604}," sessionId",[211,3548,234],{"class":217},[211,3550,3546],{"class":279},[211,3552,288],{"class":217},[211,3554,3538],{"class":279},[211,3556,2015],{"class":217},[211,3558,3559],{"class":217}," :",[211,3561,3562],{"class":217}," {}",[211,3564,501],{"class":279},[211,3566,3567],{"class":213,"line":321},[211,3568,318],{"class":217},[211,3570,3571,3573],{"class":213,"line":498},[211,3572,2015],{"class":217},[211,3574,501],{"class":279},[163,3576,3577,3578,234],{},"The server route calls the Discovery Engine Answer API. Wire your env vars into Nuxt's runtime config so the server can read them. Add to ",[172,3579,3580],{},"nuxt.config.ts",[202,3582,3584],{"className":3400,"code":3583,"language":3402,"meta":207,"style":207},"// nuxt.config.ts\nexport default defineNuxtConfig({\n  runtimeConfig: {\n    askNico: {\n      searchProject: process.env.ASK_NICO_PROJECT_NUMBER || process.env.GCP_PROJECT || '',\n      searchLocation: process.env.ASK_NICO_SEARCH_LOCATION || 'global',\n      searchDataStore: process.env.ASK_NICO_DATA_STORE || '',\n      searchEngineId: process.env.ASK_NICO_ENGINE_ID || '',\n      geminiLocation: process.env.ASK_NICO_GEMINI_LOCATION || 'us-central1'\n    }\n  }\n})\n",[172,3585,3586,3591,3606,3615,3624,3661,3689,3713,3737,3763,3767,3771],{"__ignoreMap":207},[211,3587,3588],{"class":213,"line":214},[211,3589,3590],{"class":1343},"// nuxt.config.ts\n",[211,3592,3593,3596,3599,3602,3604],{"class":213,"line":221},[211,3594,3595],{"class":1363},"export",[211,3597,3598],{"class":1363}," default",[211,3600,3601],{"class":1474}," defineNuxtConfig",[211,3603,1477],{"class":279},[211,3605,218],{"class":217},[211,3607,3608,3611,3613],{"class":213,"line":240},[211,3609,3610],{"class":1604},"  runtimeConfig",[211,3612,234],{"class":217},[211,3614,237],{"class":217},[211,3616,3617,3620,3622],{"class":213,"line":260},[211,3618,3619],{"class":1604},"    askNico",[211,3621,234],{"class":217},[211,3623,237],{"class":217},[211,3625,3626,3629,3631,3633,3635,3637,3639,3642,3644,3646,3648,3650,3652,3655,3657,3659],{"class":213,"line":295},[211,3627,3628],{"class":1604},"      searchProject",[211,3630,234],{"class":217},[211,3632,2590],{"class":279},[211,3634,288],{"class":217},[211,3636,2595],{"class":279},[211,3638,288],{"class":217},[211,3640,3641],{"class":279},"ASK_NICO_PROJECT_NUMBER ",[211,3643,2480],{"class":217},[211,3645,2590],{"class":279},[211,3647,288],{"class":217},[211,3649,2595],{"class":279},[211,3651,288],{"class":217},[211,3653,3654],{"class":279},"GCP_PROJECT ",[211,3656,2480],{"class":217},[211,3658,1707],{"class":217},[211,3660,257],{"class":217},[211,3662,3663,3666,3668,3670,3672,3674,3676,3679,3681,3683,3685,3687],{"class":213,"line":315},[211,3664,3665],{"class":1604},"      searchLocation",[211,3667,234],{"class":217},[211,3669,2590],{"class":279},[211,3671,288],{"class":217},[211,3673,2595],{"class":279},[211,3675,288],{"class":217},[211,3677,3678],{"class":279},"ASK_NICO_SEARCH_LOCATION ",[211,3680,2480],{"class":217},[211,3682,1385],{"class":217},[211,3684,3197],{"class":275},[211,3686,1488],{"class":217},[211,3688,257],{"class":217},[211,3690,3691,3694,3696,3698,3700,3702,3704,3707,3709,3711],{"class":213,"line":321},[211,3692,3693],{"class":1604},"      searchDataStore",[211,3695,234],{"class":217},[211,3697,2590],{"class":279},[211,3699,288],{"class":217},[211,3701,2595],{"class":279},[211,3703,288],{"class":217},[211,3705,3706],{"class":279},"ASK_NICO_DATA_STORE ",[211,3708,2480],{"class":217},[211,3710,1707],{"class":217},[211,3712,257],{"class":217},[211,3714,3715,3718,3720,3722,3724,3726,3728,3731,3733,3735],{"class":213,"line":498},[211,3716,3717],{"class":1604},"      searchEngineId",[211,3719,234],{"class":217},[211,3721,2590],{"class":279},[211,3723,288],{"class":217},[211,3725,2595],{"class":279},[211,3727,288],{"class":217},[211,3729,3730],{"class":279},"ASK_NICO_ENGINE_ID ",[211,3732,2480],{"class":217},[211,3734,1707],{"class":217},[211,3736,257],{"class":217},[211,3738,3739,3742,3744,3746,3748,3750,3752,3755,3757,3759,3761],{"class":213,"line":504},[211,3740,3741],{"class":1604},"      geminiLocation",[211,3743,234],{"class":217},[211,3745,2590],{"class":279},[211,3747,288],{"class":217},[211,3749,2595],{"class":279},[211,3751,288],{"class":217},[211,3753,3754],{"class":279},"ASK_NICO_GEMINI_LOCATION ",[211,3756,2480],{"class":217},[211,3758,1385],{"class":217},[211,3760,1300],{"class":275},[211,3762,1391],{"class":217},[211,3764,3765],{"class":213,"line":509},[211,3766,2537],{"class":217},[211,3768,3769],{"class":213,"line":515},[211,3770,318],{"class":217},[211,3772,3773,3775],{"class":213,"line":1535},[211,3774,2015],{"class":217},[211,3776,501],{"class":279},[163,3778,3779,3780,3783,3784,3787],{},"In the server route, build the ",[172,3781,3782],{},"servingConfig"," path from your env vars. Use the engine if set, otherwise the data store. For multi-turn conversations, create a session when none is provided and return ",[172,3785,3786],{},"sessionId"," so the client can send it on follow-up messages:",[202,3789,3791],{"className":3400,"code":3790,"language":3402,"meta":207,"style":207},"// server/api/lab/ask-nico/chat.post.ts (simplified)\n// body = await readBody(event) with messages and optional sessionId\nconst config = useRuntimeConfig().askNico\nconst project = config.searchProject || process.env.ASK_NICO_PROJECT_NUMBER\nconst location = config.searchLocation || 'global'\nconst servingConfig = config.searchEngineId\n  ? `projects/${project}/locations/${location}/collections/default_collection/engines/${config.searchEngineId}/servingConfigs/default_search`\n  : `projects/${project}/locations/${location}/collections/default_collection/dataStores/${config.searchDataStore}/servingConfigs/default_search`\n\nlet sessionToUse = body.sessionId?.trim() || null\nif (!sessionToUse && config.searchEngineId) {\n  const sessionsParent = `projects/${project}/locations/${location}/collections/default_collection/engines/${config.searchEngineId}`\n  const createRes = await fetch(`https://discoveryengine.googleapis.com/v1/${sessionsParent}/sessions`, {\n    method: 'POST',\n    headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json' },\n    body: JSON.stringify({ userPseudoId: `ask-nico-${crypto.randomUUID()}` })\n  })\n  if (createRes.ok) {\n    const created = await createRes.json()\n    sessionToUse = created.name ?? null\n  }\n}\n\nconst answerUrl = `https://discoveryengine.googleapis.com/v1/${servingConfig}:answer`\nconst res = await fetch(answerUrl, {\n  method: 'POST',\n  headers: {\n    'Authorization': `Bearer ${accessToken}`,\n    'Content-Type': 'application/json'\n  },\n  body: JSON.stringify({\n    query: { text: query },\n    ...(sessionToUse ? { session: sessionToUse } : {}),\n    answerGenerationSpec: {\n      promptSpec: { preamble: ANSWER_PREAMBLE },\n      includeCitations: true,\n      answerLanguageCode: 'en'\n    }\n  })\n})\n\n// Return sessionId so the client can send it on the next turn\nconst data = await res.json()\nreturn { reply: data.answer?.answerText ?? '', citations: {...}, sessionId: data.session?.name ?? sessionToUse }\n",[172,3792,3793,3798,3803,3822,3852,3876,3892,3940,3981,3985,4014,4037,4076,4113,4128,4179,4226,4233,4251,4270,4288,4292,4296,4300,4324,4344,4358,4367,4390,4406,4411,4427,4446,4474,4483,4502,4513,4527,4531,4537,4543,4547,4552,4572],{"__ignoreMap":207},[211,3794,3795],{"class":213,"line":214},[211,3796,3797],{"class":1343},"// server/api/lab/ask-nico/chat.post.ts (simplified)\n",[211,3799,3800],{"class":213,"line":221},[211,3801,3802],{"class":1343},"// body = await readBody(event) with messages and optional sessionId\n",[211,3804,3805,3807,3810,3812,3815,3817,3819],{"class":213,"line":240},[211,3806,1465],{"class":227},[211,3808,3809],{"class":279}," config ",[211,3811,1471],{"class":217},[211,3813,3814],{"class":1474}," useRuntimeConfig",[211,3816,3029],{"class":279},[211,3818,288],{"class":217},[211,3820,3821],{"class":279},"askNico\n",[211,3823,3824,3826,3829,3831,3834,3836,3839,3841,3843,3845,3847,3849],{"class":213,"line":260},[211,3825,1465],{"class":227},[211,3827,3828],{"class":279}," project ",[211,3830,1471],{"class":217},[211,3832,3833],{"class":279}," config",[211,3835,288],{"class":217},[211,3837,3838],{"class":279},"searchProject ",[211,3840,2480],{"class":217},[211,3842,2590],{"class":279},[211,3844,288],{"class":217},[211,3846,2595],{"class":279},[211,3848,288],{"class":217},[211,3850,3851],{"class":279},"ASK_NICO_PROJECT_NUMBER\n",[211,3853,3854,3856,3859,3861,3863,3865,3868,3870,3872,3874],{"class":213,"line":295},[211,3855,1465],{"class":227},[211,3857,3858],{"class":279}," location ",[211,3860,1471],{"class":217},[211,3862,3833],{"class":279},[211,3864,288],{"class":217},[211,3866,3867],{"class":279},"searchLocation ",[211,3869,2480],{"class":217},[211,3871,1385],{"class":217},[211,3873,3197],{"class":275},[211,3875,1391],{"class":217},[211,3877,3878,3880,3883,3885,3887,3889],{"class":213,"line":315},[211,3879,1465],{"class":227},[211,3881,3882],{"class":279}," servingConfig ",[211,3884,1471],{"class":217},[211,3886,3833],{"class":279},[211,3888,288],{"class":217},[211,3890,3891],{"class":279},"searchEngineId\n",[211,3893,3894,3897,3899,3902,3904,3907,3909,3912,3914,3917,3919,3922,3924,3927,3929,3932,3934,3937],{"class":213,"line":321},[211,3895,3896],{"class":217},"  ?",[211,3898,1922],{"class":217},[211,3900,3901],{"class":275},"projects/",[211,3903,1928],{"class":217},[211,3905,3906],{"class":279},"project",[211,3908,2015],{"class":217},[211,3910,3911],{"class":275},"/locations/",[211,3913,1928],{"class":217},[211,3915,3916],{"class":279},"location",[211,3918,2015],{"class":217},[211,3920,3921],{"class":275},"/collections/default_collection/engines/",[211,3923,1928],{"class":217},[211,3925,3926],{"class":279},"config",[211,3928,288],{"class":217},[211,3930,3931],{"class":279},"searchEngineId",[211,3933,2015],{"class":217},[211,3935,3936],{"class":275},"/servingConfigs/default_search",[211,3938,3939],{"class":217},"`\n",[211,3941,3942,3945,3947,3949,3951,3953,3955,3957,3959,3961,3963,3966,3968,3970,3972,3975,3977,3979],{"class":213,"line":498},[211,3943,3944],{"class":217},"  :",[211,3946,1922],{"class":217},[211,3948,3901],{"class":275},[211,3950,1928],{"class":217},[211,3952,3906],{"class":279},[211,3954,2015],{"class":217},[211,3956,3911],{"class":275},[211,3958,1928],{"class":217},[211,3960,3916],{"class":279},[211,3962,2015],{"class":217},[211,3964,3965],{"class":275},"/collections/default_collection/dataStores/",[211,3967,1928],{"class":217},[211,3969,3926],{"class":279},[211,3971,288],{"class":217},[211,3973,3974],{"class":279},"searchDataStore",[211,3976,2015],{"class":217},[211,3978,3936],{"class":275},[211,3980,3939],{"class":217},[211,3982,3983],{"class":213,"line":504},[211,3984,475],{"emptyLinePlaceholder":474},[211,3986,3987,3990,3993,3995,3998,4000,4002,4005,4007,4009,4011],{"class":213,"line":509},[211,3988,3989],{"class":227},"let",[211,3991,3992],{"class":279}," sessionToUse ",[211,3994,1471],{"class":217},[211,3996,3997],{"class":279}," body",[211,3999,288],{"class":217},[211,4001,3786],{"class":279},[211,4003,4004],{"class":217},"?.",[211,4006,1802],{"class":1474},[211,4008,2351],{"class":279},[211,4010,2480],{"class":217},[211,4012,4013],{"class":217}," null\n",[211,4015,4016,4019,4021,4023,4026,4028,4030,4032,4035],{"class":213,"line":515},[211,4017,4018],{"class":1363},"if",[211,4020,1575],{"class":279},[211,4022,1998],{"class":217},[211,4024,4025],{"class":279},"sessionToUse ",[211,4027,2354],{"class":217},[211,4029,3833],{"class":279},[211,4031,288],{"class":217},[211,4033,4034],{"class":279},"searchEngineId) ",[211,4036,218],{"class":217},[211,4038,4039,4041,4044,4046,4048,4050,4052,4054,4056,4058,4060,4062,4064,4066,4068,4070,4072,4074],{"class":213,"line":1535},[211,4040,1842],{"class":227},[211,4042,4043],{"class":279}," sessionsParent",[211,4045,1848],{"class":217},[211,4047,1922],{"class":217},[211,4049,3901],{"class":275},[211,4051,1928],{"class":217},[211,4053,3906],{"class":279},[211,4055,2015],{"class":217},[211,4057,3911],{"class":275},[211,4059,1928],{"class":217},[211,4061,3916],{"class":279},[211,4063,2015],{"class":217},[211,4065,3921],{"class":275},[211,4067,1928],{"class":217},[211,4069,3926],{"class":279},[211,4071,288],{"class":217},[211,4073,3931],{"class":279},[211,4075,1934],{"class":217},[211,4077,4078,4080,4083,4085,4087,4090,4092,4094,4097,4099,4102,4104,4107,4109,4111],{"class":213,"line":1561},[211,4079,1842],{"class":227},[211,4081,4082],{"class":279}," createRes",[211,4084,1848],{"class":217},[211,4086,3421],{"class":1363},[211,4088,4089],{"class":1474}," fetch",[211,4091,1477],{"class":1604},[211,4093,1772],{"class":217},[211,4095,4096],{"class":275},"https://discoveryengine.googleapis.com/v1/",[211,4098,1928],{"class":217},[211,4100,4101],{"class":279},"sessionsParent",[211,4103,2015],{"class":217},[211,4105,4106],{"class":275},"/sessions",[211,4108,1772],{"class":217},[211,4110,1373],{"class":217},[211,4112,237],{"class":217},[211,4114,4115,4118,4120,4122,4124,4126],{"class":213,"line":1566},[211,4116,4117],{"class":1604},"    method",[211,4119,234],{"class":217},[211,4121,1385],{"class":217},[211,4123,3449],{"class":275},[211,4125,1488],{"class":217},[211,4127,257],{"class":217},[211,4129,4130,4133,4135,4137,4139,4142,4144,4146,4148,4151,4153,4156,4158,4160,4162,4165,4167,4169,4171,4174,4176],{"class":213,"line":1586},[211,4131,4132],{"class":1604},"    headers",[211,4134,234],{"class":217},[211,4136,1367],{"class":217},[211,4138,1385],{"class":217},[211,4140,4141],{"class":1604},"Authorization",[211,4143,1488],{"class":217},[211,4145,234],{"class":217},[211,4147,1922],{"class":217},[211,4149,4150],{"class":275},"Bearer ",[211,4152,1928],{"class":217},[211,4154,4155],{"class":279},"accessToken",[211,4157,2029],{"class":217},[211,4159,1373],{"class":217},[211,4161,1385],{"class":217},[211,4163,4164],{"class":1604},"Content-Type",[211,4166,1488],{"class":217},[211,4168,234],{"class":217},[211,4170,1385],{"class":217},[211,4172,4173],{"class":275},"application/json",[211,4175,1488],{"class":217},[211,4177,4178],{"class":217}," },\n",[211,4180,4181,4184,4186,4189,4191,4194,4196,4198,4201,4203,4205,4208,4210,4213,4215,4218,4220,4222,4224],{"class":213,"line":1595},[211,4182,4183],{"class":1604},"    body",[211,4185,234],{"class":217},[211,4187,4188],{"class":279}," JSON",[211,4190,288],{"class":217},[211,4192,4193],{"class":1474},"stringify",[211,4195,1477],{"class":1604},[211,4197,1001],{"class":217},[211,4199,4200],{"class":1604}," userPseudoId",[211,4202,234],{"class":217},[211,4204,1922],{"class":217},[211,4206,4207],{"class":275},"ask-nico-",[211,4209,1928],{"class":217},[211,4211,4212],{"class":279},"crypto",[211,4214,288],{"class":217},[211,4216,4217],{"class":1474},"randomUUID",[211,4219,3029],{"class":279},[211,4221,2029],{"class":217},[211,4223,1379],{"class":217},[211,4225,501],{"class":1604},[211,4227,4228,4231],{"class":213,"line":1640},[211,4229,4230],{"class":217},"  }",[211,4232,501],{"class":1604},[211,4234,4235,4237,4239,4242,4244,4247,4249],{"class":213,"line":1676},[211,4236,1993],{"class":1363},[211,4238,1575],{"class":1604},[211,4240,4241],{"class":279},"createRes",[211,4243,288],{"class":217},[211,4245,4246],{"class":279},"ok",[211,4248,2003],{"class":1604},[211,4250,218],{"class":217},[211,4252,4253,4255,4258,4260,4262,4264,4266,4268],{"class":213,"line":1712},[211,4254,2287],{"class":227},[211,4256,4257],{"class":279}," created",[211,4259,1848],{"class":217},[211,4261,3421],{"class":1363},[211,4263,4082],{"class":279},[211,4265,288],{"class":217},[211,4267,206],{"class":1474},[211,4269,1805],{"class":1604},[211,4271,4272,4275,4277,4279,4281,4283,4286],{"class":213,"line":1761},[211,4273,4274],{"class":279},"    sessionToUse",[211,4276,1848],{"class":217},[211,4278,4257],{"class":279},[211,4280,288],{"class":217},[211,4282,2307],{"class":279},[211,4284,4285],{"class":217}," ??",[211,4287,4013],{"class":217},[211,4289,4290],{"class":213,"line":1797},[211,4291,318],{"class":217},[211,4293,4294],{"class":213,"line":1808},[211,4295,324],{"class":217},[211,4297,4298],{"class":213,"line":1813},[211,4299,475],{"emptyLinePlaceholder":474},[211,4301,4302,4304,4307,4309,4311,4313,4315,4317,4319,4322],{"class":213,"line":1818},[211,4303,1465],{"class":227},[211,4305,4306],{"class":279}," answerUrl ",[211,4308,1471],{"class":217},[211,4310,1922],{"class":217},[211,4312,4096],{"class":275},[211,4314,1928],{"class":217},[211,4316,3782],{"class":279},[211,4318,2015],{"class":217},[211,4320,4321],{"class":275},":answer",[211,4323,3939],{"class":217},[211,4325,4326,4328,4331,4333,4335,4337,4340,4342],{"class":213,"line":1839},[211,4327,1465],{"class":227},[211,4329,4330],{"class":279}," res ",[211,4332,1471],{"class":217},[211,4334,3421],{"class":1363},[211,4336,4089],{"class":1474},[211,4338,4339],{"class":279},"(answerUrl",[211,4341,1373],{"class":217},[211,4343,237],{"class":217},[211,4345,4346,4348,4350,4352,4354,4356],{"class":213,"line":1912},[211,4347,3442],{"class":1604},[211,4349,234],{"class":217},[211,4351,1385],{"class":217},[211,4353,3449],{"class":275},[211,4355,1488],{"class":217},[211,4357,257],{"class":217},[211,4359,4360,4363,4365],{"class":213,"line":1937},[211,4361,4362],{"class":1604},"  headers",[211,4364,234],{"class":217},[211,4366,237],{"class":217},[211,4368,4369,4372,4374,4376,4378,4380,4382,4384,4386,4388],{"class":213,"line":1990},[211,4370,4371],{"class":217},"    '",[211,4373,4141],{"class":1604},[211,4375,1488],{"class":217},[211,4377,234],{"class":217},[211,4379,1922],{"class":217},[211,4381,4150],{"class":275},[211,4383,1928],{"class":217},[211,4385,4155],{"class":279},[211,4387,2029],{"class":217},[211,4389,257],{"class":217},[211,4391,4392,4394,4396,4398,4400,4402,4404],{"class":213,"line":2038},[211,4393,4371],{"class":217},[211,4395,4164],{"class":1604},[211,4397,1488],{"class":217},[211,4399,234],{"class":217},[211,4401,1385],{"class":217},[211,4403,4173],{"class":275},[211,4405,1391],{"class":217},[211,4407,4408],{"class":213,"line":2150},[211,4409,4410],{"class":217},"  },\n",[211,4412,4413,4415,4417,4419,4421,4423,4425],{"class":213,"line":2192},[211,4414,3458],{"class":1604},[211,4416,234],{"class":217},[211,4418,4188],{"class":279},[211,4420,288],{"class":217},[211,4422,4193],{"class":1474},[211,4424,1477],{"class":279},[211,4426,218],{"class":217},[211,4428,4429,4432,4434,4436,4438,4440,4443],{"class":213,"line":2197},[211,4430,4431],{"class":1604},"    query",[211,4433,234],{"class":217},[211,4435,1367],{"class":217},[211,4437,1947],{"class":1604},[211,4439,234],{"class":217},[211,4441,4442],{"class":279}," query ",[211,4444,4445],{"class":217},"},\n",[211,4447,4448,4450,4453,4455,4457,4460,4462,4464,4466,4468,4470,4472],{"class":213,"line":2202},[211,4449,3530],{"class":217},[211,4451,4452],{"class":279},"(sessionToUse ",[211,4454,3541],{"class":217},[211,4456,1367],{"class":217},[211,4458,4459],{"class":1604}," session",[211,4461,234],{"class":217},[211,4463,3992],{"class":279},[211,4465,2015],{"class":217},[211,4467,3559],{"class":217},[211,4469,3562],{"class":217},[211,4471,1581],{"class":279},[211,4473,257],{"class":217},[211,4475,4476,4479,4481],{"class":213,"line":2229},[211,4477,4478],{"class":1604},"    answerGenerationSpec",[211,4480,234],{"class":217},[211,4482,237],{"class":217},[211,4484,4485,4488,4490,4492,4495,4497,4500],{"class":213,"line":2242},[211,4486,4487],{"class":1604},"      promptSpec",[211,4489,234],{"class":217},[211,4491,1367],{"class":217},[211,4493,4494],{"class":1604}," preamble",[211,4496,234],{"class":217},[211,4498,4499],{"class":279}," ANSWER_PREAMBLE ",[211,4501,4445],{"class":217},[211,4503,4504,4507,4509,4511],{"class":213,"line":2284},[211,4505,4506],{"class":1604},"      includeCitations",[211,4508,234],{"class":217},[211,4510,2274],{"class":2273},[211,4512,257],{"class":217},[211,4514,4515,4518,4520,4522,4525],{"class":213,"line":2312},[211,4516,4517],{"class":1604},"      answerLanguageCode",[211,4519,234],{"class":217},[211,4521,1385],{"class":217},[211,4523,4524],{"class":275},"en",[211,4526,1391],{"class":217},[211,4528,4529],{"class":213,"line":2335},[211,4530,2537],{"class":217},[211,4532,4533,4535],{"class":213,"line":2401},[211,4534,4230],{"class":217},[211,4536,501],{"class":279},[211,4538,4539,4541],{"class":213,"line":2432},[211,4540,2015],{"class":217},[211,4542,501],{"class":279},[211,4544,4545],{"class":213,"line":2506},[211,4546,475],{"emptyLinePlaceholder":474},[211,4548,4549],{"class":213,"line":2534},[211,4550,4551],{"class":1343},"// Return sessionId so the client can send it on the next turn\n",[211,4553,4554,4556,4559,4561,4563,4566,4568,4570],{"class":213,"line":2540},[211,4555,1465],{"class":227},[211,4557,4558],{"class":279}," data ",[211,4560,1471],{"class":217},[211,4562,3421],{"class":1363},[211,4564,4565],{"class":279}," res",[211,4567,288],{"class":217},[211,4569,206],{"class":1474},[211,4571,1805],{"class":279},[211,4573,4574,4576,4578,4581,4583,4586,4588,4591,4593,4596,4599,4601,4603,4606,4608,4611,4613,4615,4617,4619,4622,4624,4627,4629,4631],{"class":213,"line":2545},[211,4575,2006],{"class":1363},[211,4577,1367],{"class":217},[211,4579,4580],{"class":1604}," reply",[211,4582,234],{"class":217},[211,4584,4585],{"class":279}," data",[211,4587,288],{"class":217},[211,4589,4590],{"class":279},"answer",[211,4592,4004],{"class":217},[211,4594,4595],{"class":279},"answerText ",[211,4597,4598],{"class":217},"??",[211,4600,1707],{"class":217},[211,4602,1373],{"class":217},[211,4604,4605],{"class":1604}," citations",[211,4607,234],{"class":217},[211,4609,4610],{"class":217}," {...},",[211,4612,3546],{"class":1604},[211,4614,234],{"class":217},[211,4616,4585],{"class":279},[211,4618,288],{"class":217},[211,4620,4621],{"class":279},"session",[211,4623,4004],{"class":217},[211,4625,4626],{"class":279},"name ",[211,4628,4598],{"class":217},[211,4630,3992],{"class":279},[211,4632,324],{"class":217},[163,4634,4635,4636,4638],{},"Here's a simplified version of the Vue component that wires it up. Replace ",[172,4637,3431],{}," with your own API path if different:",[202,4640,4644],{"className":4641,"code":4642,"language":4643,"meta":207,"style":207},"language-vue shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","\u003Cscript setup lang=\"ts\">\nconst messages = ref\u003CArray\u003C{ role: 'user' | 'assistant', content: string }>>([])\nconst input = ref('')\nconst isLoading = ref(false)\n\nconst sendMessage = async () => {\n  const text = input.value.trim()\n  if (!text || isLoading.value) return\n\n  messages.value.push({ role: 'user', content: text })\n  input.value = ''\n  isLoading.value = true\n\n  const assistantMsg = { role: 'assistant' as const, content: '' }\n  messages.value.push(assistantMsg)\n\n  try {\n    const res = await $fetch\u003C{ reply: string }>('/api/lab/ask-nico/chat', {\n      method: 'POST',\n      body: { messages: messages.value.filter(m => m.content).map(m => ({ role: m.role, content: m.content })) }\n    })\n    assistantMsg.content = res.reply ?? 'No response.'\n  } catch {\n    assistantMsg.content = 'Something went wrong.'\n  } finally {\n    isLoading.value = false\n  }\n}\n\u003C/script>\n\n\u003Ctemplate>\n  \u003Cdiv class=\"flex flex-col p-4 gap-4\">\n    \u003Cdiv class=\"space-y-2 overflow-y-auto\">\n      \u003Cdiv v-for=\"(msg, i) in messages\" :key=\"i\" :class=\"msg.role === 'user' ? 'text-right' : ''\">\n        \u003Cdiv v-if=\"msg.role === 'user'\" class=\"inline-block px-3 py-2 rounded bg-blue-600 text-white whitespace-pre-wrap\">\n          {{ msg.content }}\n        \u003C/div>\n        \u003Cdiv v-else class=\"whitespace-pre-wrap\">{{ msg.content }}\u003C/div>\n      \u003C/div>\n      \u003Cdiv v-if=\"isLoading\" class=\"text-gray-500\">Thinking...\u003C/div>\n    \u003C/div>\n    \u003Cform @submit.prevent=\"sendMessage\" class=\"flex gap-2\">\n      \u003Ctextarea v-model=\"input\" placeholder=\"Ask anything...\" class=\"flex-1 p-2 border rounded\" rows=\"2\" />\n      \u003Cbutton type=\"submit\" :disabled=\"!input.trim() || isLoading\" class=\"px-3 py-2 rounded bg-blue-600 text-white disabled:opacity-50\">\n        Send\n      \u003C/button>\n    \u003C/form>\n  \u003C/div>\n\u003C/template>\n","vue",[172,4645,4646,4672,4728,4746,4764,4768,4786,4807,4831,4835,4874,4888,4902,4906,4944,4963,4967,4974,5009,5024,5103,5109,5136,5145,5162,5171,5185,5189,5193,5202,5206,5215,5237,5257,5302,5334,5339,5348,5380,5389,5428,5437,5469,5525,5569,5574,5582,5590,5599],{"__ignoreMap":207},[211,4647,4648,4651,4654,4657,4660,4662,4664,4667,4669],{"class":213,"line":214},[211,4649,4650],{"class":217},"\u003C",[211,4652,4653],{"class":1604},"script",[211,4655,4656],{"class":227}," setup",[211,4658,4659],{"class":227}," lang",[211,4661,1471],{"class":217},[211,4663,231],{"class":217},[211,4665,4666],{"class":275},"ts",[211,4668,231],{"class":217},[211,4670,4671],{"class":217},">\n",[211,4673,4674,4676,4679,4681,4684,4686,4689,4692,4694,4696,4698,4701,4703,4706,4708,4711,4713,4715,4717,4719,4722,4725],{"class":213,"line":221},[211,4675,1465],{"class":227},[211,4677,4678],{"class":279}," messages ",[211,4680,1471],{"class":217},[211,4682,4683],{"class":1474}," ref",[211,4685,4650],{"class":217},[211,4687,4688],{"class":246},"Array",[211,4690,4691],{"class":217},"\u003C{",[211,4693,3495],{"class":1604},[211,4695,234],{"class":217},[211,4697,1385],{"class":217},[211,4699,4700],{"class":275},"user",[211,4702,1488],{"class":217},[211,4704,4705],{"class":217}," |",[211,4707,1385],{"class":217},[211,4709,4710],{"class":275},"assistant",[211,4712,1488],{"class":217},[211,4714,1373],{"class":217},[211,4716,3510],{"class":1604},[211,4718,234],{"class":217},[211,4720,4721],{"class":246}," string",[211,4723,4724],{"class":217}," }>>",[211,4726,4727],{"class":279},"([])\n",[211,4729,4730,4732,4735,4737,4739,4741,4744],{"class":213,"line":240},[211,4731,1465],{"class":227},[211,4733,4734],{"class":279}," input ",[211,4736,1471],{"class":217},[211,4738,4683],{"class":1474},[211,4740,1477],{"class":279},[211,4742,4743],{"class":217},"''",[211,4745,501],{"class":279},[211,4747,4748,4750,4753,4755,4757,4759,4762],{"class":213,"line":260},[211,4749,1465],{"class":227},[211,4751,4752],{"class":279}," isLoading ",[211,4754,1471],{"class":217},[211,4756,4683],{"class":1474},[211,4758,1477],{"class":279},[211,4760,4761],{"class":2273},"false",[211,4763,501],{"class":279},[211,4765,4766],{"class":213,"line":295},[211,4767,475],{"emptyLinePlaceholder":474},[211,4769,4770,4772,4775,4777,4780,4782,4784],{"class":213,"line":315},[211,4771,1465],{"class":227},[211,4773,4774],{"class":279}," sendMessage ",[211,4776,1471],{"class":217},[211,4778,4779],{"class":227}," async",[211,4781,2575],{"class":217},[211,4783,2085],{"class":227},[211,4785,237],{"class":217},[211,4787,4788,4790,4792,4794,4797,4799,4801,4803,4805],{"class":213,"line":321},[211,4789,1842],{"class":227},[211,4791,1947],{"class":279},[211,4793,1848],{"class":217},[211,4795,4796],{"class":279}," input",[211,4798,288],{"class":217},[211,4800,3477],{"class":279},[211,4802,288],{"class":217},[211,4804,1802],{"class":1474},[211,4806,1805],{"class":1604},[211,4808,4809,4811,4813,4815,4817,4819,4822,4824,4826,4828],{"class":213,"line":498},[211,4810,1993],{"class":1363},[211,4812,1575],{"class":1604},[211,4814,1998],{"class":217},[211,4816,903],{"class":279},[211,4818,2636],{"class":217},[211,4820,4821],{"class":279}," isLoading",[211,4823,288],{"class":217},[211,4825,3477],{"class":279},[211,4827,2003],{"class":1604},[211,4829,4830],{"class":1363},"return\n",[211,4832,4833],{"class":213,"line":504},[211,4834,475],{"emptyLinePlaceholder":474},[211,4836,4837,4840,4842,4844,4846,4848,4850,4852,4854,4856,4858,4860,4862,4864,4866,4868,4870,4872],{"class":213,"line":509},[211,4838,4839],{"class":279},"  messages",[211,4841,288],{"class":217},[211,4843,3477],{"class":279},[211,4845,288],{"class":217},[211,4847,2409],{"class":1474},[211,4849,1477],{"class":1604},[211,4851,1001],{"class":217},[211,4853,3495],{"class":1604},[211,4855,234],{"class":217},[211,4857,1385],{"class":217},[211,4859,4700],{"class":275},[211,4861,1488],{"class":217},[211,4863,1373],{"class":217},[211,4865,3510],{"class":1604},[211,4867,234],{"class":217},[211,4869,1947],{"class":279},[211,4871,1379],{"class":217},[211,4873,501],{"class":1604},[211,4875,4876,4879,4881,4883,4885],{"class":213,"line":515},[211,4877,4878],{"class":279},"  input",[211,4880,288],{"class":217},[211,4882,3477],{"class":279},[211,4884,1848],{"class":217},[211,4886,4887],{"class":217}," ''\n",[211,4889,4890,4893,4895,4897,4899],{"class":213,"line":1535},[211,4891,4892],{"class":279},"  isLoading",[211,4894,288],{"class":217},[211,4896,3477],{"class":279},[211,4898,1848],{"class":217},[211,4900,4901],{"class":2273}," true\n",[211,4903,4904],{"class":213,"line":1561},[211,4905,475],{"emptyLinePlaceholder":474},[211,4907,4908,4910,4913,4915,4917,4919,4921,4923,4925,4927,4930,4933,4935,4937,4939,4941],{"class":213,"line":1566},[211,4909,1842],{"class":227},[211,4911,4912],{"class":279}," assistantMsg",[211,4914,1848],{"class":217},[211,4916,1367],{"class":217},[211,4918,3495],{"class":1604},[211,4920,234],{"class":217},[211,4922,1385],{"class":217},[211,4924,4710],{"class":275},[211,4926,1488],{"class":217},[211,4928,4929],{"class":1363}," as",[211,4931,4932],{"class":227}," const",[211,4934,1373],{"class":217},[211,4936,3510],{"class":1604},[211,4938,234],{"class":217},[211,4940,1707],{"class":217},[211,4942,4943],{"class":217}," }\n",[211,4945,4946,4948,4950,4952,4954,4956,4958,4961],{"class":213,"line":1586},[211,4947,4839],{"class":279},[211,4949,288],{"class":217},[211,4951,3477],{"class":279},[211,4953,288],{"class":217},[211,4955,2409],{"class":1474},[211,4957,1477],{"class":1604},[211,4959,4960],{"class":279},"assistantMsg",[211,4962,501],{"class":1604},[211,4964,4965],{"class":213,"line":1595},[211,4966,475],{"emptyLinePlaceholder":474},[211,4968,4969,4972],{"class":213,"line":1640},[211,4970,4971],{"class":1363},"  try",[211,4973,237],{"class":217},[211,4975,4976,4978,4980,4982,4984,4986,4988,4990,4992,4994,4997,4999,5001,5003,5005,5007],{"class":213,"line":1676},[211,4977,2287],{"class":227},[211,4979,4565],{"class":279},[211,4981,1848],{"class":217},[211,4983,3421],{"class":1363},[211,4985,3424],{"class":1474},[211,4987,4691],{"class":217},[211,4989,4580],{"class":1604},[211,4991,234],{"class":217},[211,4993,4721],{"class":246},[211,4995,4996],{"class":217}," }>",[211,4998,1477],{"class":1604},[211,5000,1488],{"class":217},[211,5002,3431],{"class":275},[211,5004,1488],{"class":217},[211,5006,1373],{"class":217},[211,5008,237],{"class":217},[211,5010,5011,5014,5016,5018,5020,5022],{"class":213,"line":1712},[211,5012,5013],{"class":1604},"      method",[211,5015,234],{"class":217},[211,5017,1385],{"class":217},[211,5019,3449],{"class":275},[211,5021,1488],{"class":217},[211,5023,257],{"class":217},[211,5025,5026,5029,5031,5033,5035,5037,5039,5041,5043,5045,5047,5049,5051,5053,5055,5057,5059,5061,5063,5065,5067,5069,5071,5073,5075,5077,5079,5081,5083,5085,5087,5089,5091,5093,5095,5097,5099,5101],{"class":213,"line":1761},[211,5027,5028],{"class":1604},"      body",[211,5030,234],{"class":217},[211,5032,1367],{"class":217},[211,5034,3472],{"class":1604},[211,5036,234],{"class":217},[211,5038,3472],{"class":279},[211,5040,288],{"class":217},[211,5042,3477],{"class":279},[211,5044,288],{"class":217},[211,5046,2100],{"class":1474},[211,5048,1477],{"class":1604},[211,5050,3486],{"class":1578},[211,5052,2085],{"class":227},[211,5054,3500],{"class":279},[211,5056,288],{"class":217},[211,5058,1554],{"class":279},[211,5060,1581],{"class":1604},[211,5062,288],{"class":217},[211,5064,2077],{"class":1474},[211,5066,1477],{"class":1604},[211,5068,3486],{"class":1578},[211,5070,2085],{"class":227},[211,5072,1575],{"class":1604},[211,5074,1001],{"class":217},[211,5076,3495],{"class":1604},[211,5078,234],{"class":217},[211,5080,3500],{"class":279},[211,5082,288],{"class":217},[211,5084,3505],{"class":279},[211,5086,1373],{"class":217},[211,5088,3510],{"class":1604},[211,5090,234],{"class":217},[211,5092,3500],{"class":279},[211,5094,288],{"class":217},[211,5096,1554],{"class":279},[211,5098,1379],{"class":217},[211,5100,2279],{"class":1604},[211,5102,324],{"class":217},[211,5104,5105,5107],{"class":213,"line":1797},[211,5106,2435],{"class":217},[211,5108,501],{"class":1604},[211,5110,5111,5114,5116,5118,5120,5122,5124,5127,5129,5131,5134],{"class":213,"line":1808},[211,5112,5113],{"class":279},"    assistantMsg",[211,5115,288],{"class":217},[211,5117,1554],{"class":279},[211,5119,1848],{"class":217},[211,5121,4565],{"class":279},[211,5123,288],{"class":217},[211,5125,5126],{"class":279},"reply",[211,5128,4285],{"class":217},[211,5130,1385],{"class":217},[211,5132,5133],{"class":275},"No response.",[211,5135,1391],{"class":217},[211,5137,5138,5140,5143],{"class":213,"line":1813},[211,5139,4230],{"class":217},[211,5141,5142],{"class":1363}," catch",[211,5144,237],{"class":217},[211,5146,5147,5149,5151,5153,5155,5157,5160],{"class":213,"line":1818},[211,5148,5113],{"class":279},[211,5150,288],{"class":217},[211,5152,1554],{"class":279},[211,5154,1848],{"class":217},[211,5156,1385],{"class":217},[211,5158,5159],{"class":275},"Something went wrong.",[211,5161,1391],{"class":217},[211,5163,5164,5166,5169],{"class":213,"line":1839},[211,5165,4230],{"class":217},[211,5167,5168],{"class":1363}," finally",[211,5170,237],{"class":217},[211,5172,5173,5176,5178,5180,5182],{"class":213,"line":1912},[211,5174,5175],{"class":279},"    isLoading",[211,5177,288],{"class":217},[211,5179,3477],{"class":279},[211,5181,1848],{"class":217},[211,5183,5184],{"class":2273}," false\n",[211,5186,5187],{"class":213,"line":1937},[211,5188,318],{"class":217},[211,5190,5191],{"class":213,"line":1990},[211,5192,324],{"class":217},[211,5194,5195,5198,5200],{"class":213,"line":2038},[211,5196,5197],{"class":217},"\u003C/",[211,5199,4653],{"class":1604},[211,5201,4671],{"class":217},[211,5203,5204],{"class":213,"line":2150},[211,5205,475],{"emptyLinePlaceholder":474},[211,5207,5208,5210,5213],{"class":213,"line":2192},[211,5209,4650],{"class":217},[211,5211,5212],{"class":1604},"template",[211,5214,4671],{"class":217},[211,5216,5217,5220,5223,5226,5228,5230,5233,5235],{"class":213,"line":2197},[211,5218,5219],{"class":217},"  \u003C",[211,5221,5222],{"class":1604},"div",[211,5224,5225],{"class":227}," class",[211,5227,1471],{"class":217},[211,5229,231],{"class":217},[211,5231,5232],{"class":275},"flex flex-col p-4 gap-4",[211,5234,231],{"class":217},[211,5236,4671],{"class":217},[211,5238,5239,5242,5244,5246,5248,5250,5253,5255],{"class":213,"line":2202},[211,5240,5241],{"class":217},"    \u003C",[211,5243,5222],{"class":1604},[211,5245,5225],{"class":227},[211,5247,1471],{"class":217},[211,5249,231],{"class":217},[211,5251,5252],{"class":275},"space-y-2 overflow-y-auto",[211,5254,231],{"class":217},[211,5256,4671],{"class":217},[211,5258,5259,5262,5264,5267,5269,5271,5274,5276,5279,5281,5283,5286,5288,5291,5293,5295,5298,5300],{"class":213,"line":2229},[211,5260,5261],{"class":217},"      \u003C",[211,5263,5222],{"class":1604},[211,5265,5266],{"class":227}," v-for",[211,5268,1471],{"class":217},[211,5270,231],{"class":217},[211,5272,5273],{"class":275},"(msg, i) in messages",[211,5275,231],{"class":217},[211,5277,5278],{"class":227}," :key",[211,5280,1471],{"class":217},[211,5282,231],{"class":217},[211,5284,5285],{"class":275},"i",[211,5287,231],{"class":217},[211,5289,5290],{"class":227}," :class",[211,5292,1471],{"class":217},[211,5294,231],{"class":217},[211,5296,5297],{"class":275},"msg.role === 'user' ? 'text-right' : ''",[211,5299,231],{"class":217},[211,5301,4671],{"class":217},[211,5303,5304,5307,5309,5312,5314,5316,5319,5321,5323,5325,5327,5330,5332],{"class":213,"line":2242},[211,5305,5306],{"class":217},"        \u003C",[211,5308,5222],{"class":1604},[211,5310,5311],{"class":227}," v-if",[211,5313,1471],{"class":217},[211,5315,231],{"class":217},[211,5317,5318],{"class":275},"msg.role === 'user'",[211,5320,231],{"class":217},[211,5322,5225],{"class":227},[211,5324,1471],{"class":217},[211,5326,231],{"class":217},[211,5328,5329],{"class":275},"inline-block px-3 py-2 rounded bg-blue-600 text-white whitespace-pre-wrap",[211,5331,231],{"class":217},[211,5333,4671],{"class":217},[211,5335,5336],{"class":213,"line":2284},[211,5337,5338],{"class":279},"          {{ msg.content }}\n",[211,5340,5341,5344,5346],{"class":213,"line":2312},[211,5342,5343],{"class":217},"        \u003C/",[211,5345,5222],{"class":1604},[211,5347,4671],{"class":217},[211,5349,5350,5352,5354,5357,5359,5361,5363,5366,5368,5371,5374,5376,5378],{"class":213,"line":2335},[211,5351,5306],{"class":217},[211,5353,5222],{"class":1604},[211,5355,5356],{"class":227}," v-else",[211,5358,5225],{"class":227},[211,5360,1471],{"class":217},[211,5362,231],{"class":217},[211,5364,5365],{"class":275},"whitespace-pre-wrap",[211,5367,231],{"class":217},[211,5369,5370],{"class":217},">",[211,5372,5373],{"class":279},"{{ msg.content }}",[211,5375,5197],{"class":217},[211,5377,5222],{"class":1604},[211,5379,4671],{"class":217},[211,5381,5382,5385,5387],{"class":213,"line":2401},[211,5383,5384],{"class":217},"      \u003C/",[211,5386,5222],{"class":1604},[211,5388,4671],{"class":217},[211,5390,5391,5393,5395,5397,5399,5401,5404,5406,5408,5410,5412,5415,5417,5419,5422,5424,5426],{"class":213,"line":2432},[211,5392,5261],{"class":217},[211,5394,5222],{"class":1604},[211,5396,5311],{"class":227},[211,5398,1471],{"class":217},[211,5400,231],{"class":217},[211,5402,5403],{"class":275},"isLoading",[211,5405,231],{"class":217},[211,5407,5225],{"class":227},[211,5409,1471],{"class":217},[211,5411,231],{"class":217},[211,5413,5414],{"class":275},"text-gray-500",[211,5416,231],{"class":217},[211,5418,5370],{"class":217},[211,5420,5421],{"class":279},"Thinking...",[211,5423,5197],{"class":217},[211,5425,5222],{"class":1604},[211,5427,4671],{"class":217},[211,5429,5430,5433,5435],{"class":213,"line":2506},[211,5431,5432],{"class":217},"    \u003C/",[211,5434,5222],{"class":1604},[211,5436,4671],{"class":217},[211,5438,5439,5441,5444,5447,5449,5451,5454,5456,5458,5460,5462,5465,5467],{"class":213,"line":2534},[211,5440,5241],{"class":217},[211,5442,5443],{"class":1604},"form",[211,5445,5446],{"class":227}," @submit.prevent",[211,5448,1471],{"class":217},[211,5450,231],{"class":217},[211,5452,5453],{"class":275},"sendMessage",[211,5455,231],{"class":217},[211,5457,5225],{"class":227},[211,5459,1471],{"class":217},[211,5461,231],{"class":217},[211,5463,5464],{"class":275},"flex gap-2",[211,5466,231],{"class":217},[211,5468,4671],{"class":217},[211,5470,5471,5473,5476,5479,5481,5483,5486,5488,5491,5493,5495,5498,5500,5502,5504,5506,5509,5511,5514,5516,5518,5520,5522],{"class":213,"line":2540},[211,5472,5261],{"class":217},[211,5474,5475],{"class":1604},"textarea",[211,5477,5478],{"class":227}," v-model",[211,5480,1471],{"class":217},[211,5482,231],{"class":217},[211,5484,5485],{"class":275},"input",[211,5487,231],{"class":217},[211,5489,5490],{"class":227}," placeholder",[211,5492,1471],{"class":217},[211,5494,231],{"class":217},[211,5496,5497],{"class":275},"Ask anything...",[211,5499,231],{"class":217},[211,5501,5225],{"class":227},[211,5503,1471],{"class":217},[211,5505,231],{"class":217},[211,5507,5508],{"class":275},"flex-1 p-2 border rounded",[211,5510,231],{"class":217},[211,5512,5513],{"class":227}," rows",[211,5515,1471],{"class":217},[211,5517,231],{"class":217},[211,5519,2178],{"class":275},[211,5521,231],{"class":217},[211,5523,5524],{"class":217}," />\n",[211,5526,5527,5529,5532,5535,5537,5539,5542,5544,5547,5549,5551,5554,5556,5558,5560,5562,5565,5567],{"class":213,"line":2545},[211,5528,5261],{"class":217},[211,5530,5531],{"class":1604},"button",[211,5533,5534],{"class":227}," type",[211,5536,1471],{"class":217},[211,5538,231],{"class":217},[211,5540,5541],{"class":275},"submit",[211,5543,231],{"class":217},[211,5545,5546],{"class":227}," :disabled",[211,5548,1471],{"class":217},[211,5550,231],{"class":217},[211,5552,5553],{"class":275},"!input.trim() || isLoading",[211,5555,231],{"class":217},[211,5557,5225],{"class":227},[211,5559,1471],{"class":217},[211,5561,231],{"class":217},[211,5563,5564],{"class":275},"px-3 py-2 rounded bg-blue-600 text-white disabled:opacity-50",[211,5566,231],{"class":217},[211,5568,4671],{"class":217},[211,5570,5571],{"class":213,"line":2553},[211,5572,5573],{"class":279},"        Send\n",[211,5575,5576,5578,5580],{"class":213,"line":2558},[211,5577,5384],{"class":217},[211,5579,5531],{"class":1604},[211,5581,4671],{"class":217},[211,5583,5584,5586,5588],{"class":213,"line":2563},[211,5585,5432],{"class":217},[211,5587,5443],{"class":1604},[211,5589,4671],{"class":217},[211,5591,5592,5595,5597],{"class":213,"line":2580},[211,5593,5594],{"class":217},"  \u003C/",[211,5596,5222],{"class":1604},[211,5598,4671],{"class":217},[211,5600,5601,5603,5605],{"class":213,"line":2603},[211,5602,5197],{"class":217},[211,5604,5212],{"class":1604},[211,5606,4671],{"class":217},[163,5608,5609,5610,5613],{},"Run ",[172,5611,5612],{},"pnpm dev"," and visit your chat page to test.",[163,5615,5616],{},"The full implementation adds suggested prompts, session persistence, and a reveal-down animation.",[186,5618,5620],{"id":5619},"citation-linking","Citation Linking",[163,5622,5623,5624,5627],{},"Vertex AI Search returns file paths like ",[172,5625,5626],{},"blog/21.building-a-linkedin-ml-persona...",". My site uses clean slugs without the number prefix. Three things matter to avoid broken links or raw Markdown in the UI.",[728,5629,5631],{"id":5630},"_1-normalize-each-citation-url-before-returning","1. Normalize each citation URL before returning",[163,5633,5634,5635,1308,5638,5641],{},"Apply normalization on the server when building the citations map, not after. For every citation you add to the map, run the raw URI through ",[172,5636,5637],{},"toRelativePath",[172,5639,5640],{},"stripNumberPrefixFromPath"," so the client receives clean URLs:",[202,5643,5645],{"className":3400,"code":5644,"language":3402,"meta":207,"style":207},"/** Strip leading \"N.\" from path segments (e.g. /blog/21.building-a-linkedin... -> /blog/building-a-linkedin...). */\nfunction stripNumberPrefixFromPath(path: string): string {\n  const leadingSlash = path.startsWith('/')\n  const segments = path.split('/').map(segment => segment.replace(/^\\d+\\./, ''))\n  const joined = segments.filter(Boolean).join('/')\n  return leadingSlash ? `/${joined}` : joined\n}\n\n/** Normalize to relative path for use in Markdown links. */\nfunction toRelativePath(url: string): string {\n  try {\n    const u = new URL(url, 'https://placeholder')\n    if (u.pathname && u.pathname !== '/') return u.pathname\n    return url\n  } catch {\n    return url.startsWith('/') ? url : `/${url}`\n  }\n}\n\n// When building citations from Vertex API response, normalize each one before adding to the map:\nconst rawLink = ref.uri ?? ref.documentMetadata?.uri ?? ''  // e.g. \"https://.../blog/21.building-a-linkedin...\"\nconst cleanUrl = stripNumberPrefixFromPath(toRelativePath(rawLink))  // -> \"/blog/building-a-linkedin...\"\nconst entry = { url: cleanUrl, title: title || undefined }  // use cleanUrl, not rawLink\n",[172,5646,5647,5652,5674,5699,5763,5799,5824,5828,5832,5837,5858,5864,5892,5933,5941,5949,5986,5990,5994,5998,6003,6039,6060],{"__ignoreMap":207},[211,5648,5649],{"class":213,"line":214},[211,5650,5651],{"class":1343},"/** Strip leading \"N.\" from path segments (e.g. /blog/21.building-a-linkedin... -> /blog/building-a-linkedin...). */\n",[211,5653,5654,5656,5659,5661,5663,5665,5667,5670,5672],{"class":213,"line":221},[211,5655,1569],{"class":227},[211,5657,5658],{"class":1474}," stripNumberPrefixFromPath",[211,5660,1477],{"class":217},[211,5662,1414],{"class":1578},[211,5664,234],{"class":217},[211,5666,4721],{"class":246},[211,5668,5669],{"class":217},"):",[211,5671,4721],{"class":246},[211,5673,237],{"class":217},[211,5675,5676,5678,5681,5683,5685,5687,5689,5691,5693,5695,5697],{"class":213,"line":240},[211,5677,1842],{"class":227},[211,5679,5680],{"class":279}," leadingSlash",[211,5682,1848],{"class":217},[211,5684,2519],{"class":279},[211,5686,288],{"class":217},[211,5688,2121],{"class":1474},[211,5690,1477],{"class":1604},[211,5692,1488],{"class":217},[211,5694,1607],{"class":275},[211,5696,1488],{"class":217},[211,5698,501],{"class":1604},[211,5700,5701,5703,5706,5708,5710,5712,5714,5716,5718,5720,5722,5724,5726,5728,5730,5733,5735,5738,5740,5742,5744,5746,5748,5751,5753,5755,5757,5759,5761],{"class":213,"line":260},[211,5702,1842],{"class":227},[211,5704,5705],{"class":279}," segments",[211,5707,1848],{"class":217},[211,5709,2519],{"class":279},[211,5711,288],{"class":217},[211,5713,2061],{"class":1474},[211,5715,1477],{"class":1604},[211,5717,1488],{"class":217},[211,5719,1607],{"class":275},[211,5721,1488],{"class":217},[211,5723,1581],{"class":1604},[211,5725,288],{"class":217},[211,5727,2077],{"class":1474},[211,5729,1477],{"class":1604},[211,5731,5732],{"class":1578},"segment",[211,5734,2085],{"class":227},[211,5736,5737],{"class":279}," segment",[211,5739,288],{"class":217},[211,5741,1601],{"class":1474},[211,5743,1477],{"class":1604},[211,5745,1607],{"class":217},[211,5747,1687],{"class":1363},[211,5749,5750],{"class":275},"\\d",[211,5752,1693],{"class":217},[211,5754,1861],{"class":279},[211,5756,1607],{"class":217},[211,5758,1373],{"class":217},[211,5760,1707],{"class":217},[211,5762,2429],{"class":1604},[211,5764,5765,5767,5770,5772,5774,5776,5778,5780,5783,5785,5787,5789,5791,5793,5795,5797],{"class":213,"line":295},[211,5766,1842],{"class":227},[211,5768,5769],{"class":279}," joined",[211,5771,1848],{"class":217},[211,5773,5705],{"class":279},[211,5775,288],{"class":217},[211,5777,2100],{"class":1474},[211,5779,1477],{"class":1604},[211,5781,5782],{"class":279},"Boolean",[211,5784,1581],{"class":1604},[211,5786,288],{"class":217},[211,5788,2137],{"class":1474},[211,5790,1477],{"class":1604},[211,5792,1488],{"class":217},[211,5794,1607],{"class":275},[211,5796,1488],{"class":217},[211,5798,501],{"class":1604},[211,5800,5801,5803,5805,5808,5810,5812,5814,5817,5819,5821],{"class":213,"line":315},[211,5802,1589],{"class":1363},[211,5804,5680],{"class":279},[211,5806,5807],{"class":217}," ?",[211,5809,1922],{"class":217},[211,5811,1607],{"class":275},[211,5813,1928],{"class":217},[211,5815,5816],{"class":279},"joined",[211,5818,2029],{"class":217},[211,5820,3559],{"class":217},[211,5822,5823],{"class":279}," joined\n",[211,5825,5826],{"class":213,"line":321},[211,5827,324],{"class":217},[211,5829,5830],{"class":213,"line":498},[211,5831,475],{"emptyLinePlaceholder":474},[211,5833,5834],{"class":213,"line":504},[211,5835,5836],{"class":1343},"/** Normalize to relative path for use in Markdown links. */\n",[211,5838,5839,5841,5844,5846,5848,5850,5852,5854,5856],{"class":213,"line":509},[211,5840,1569],{"class":227},[211,5842,5843],{"class":1474}," toRelativePath",[211,5845,1477],{"class":217},[211,5847,1434],{"class":1578},[211,5849,234],{"class":217},[211,5851,4721],{"class":246},[211,5853,5669],{"class":217},[211,5855,4721],{"class":246},[211,5857,237],{"class":217},[211,5859,5860,5862],{"class":213,"line":515},[211,5861,4971],{"class":1363},[211,5863,237],{"class":217},[211,5865,5866,5868,5871,5873,5875,5877,5879,5881,5883,5885,5888,5890],{"class":213,"line":1535},[211,5867,2287],{"class":227},[211,5869,5870],{"class":279}," u",[211,5872,1848],{"class":217},[211,5874,2701],{"class":217},[211,5876,1483],{"class":1474},[211,5878,1477],{"class":1604},[211,5880,1434],{"class":279},[211,5882,1373],{"class":217},[211,5884,1385],{"class":217},[211,5886,5887],{"class":275},"https://placeholder",[211,5889,1488],{"class":217},[211,5891,501],{"class":1604},[211,5893,5894,5896,5898,5901,5903,5906,5908,5910,5912,5914,5916,5918,5920,5922,5924,5926,5928,5930],{"class":213,"line":1561},[211,5895,2338],{"class":1363},[211,5897,1575],{"class":1604},[211,5899,5900],{"class":279},"u",[211,5902,288],{"class":217},[211,5904,5905],{"class":279},"pathname",[211,5907,2111],{"class":217},[211,5909,5870],{"class":279},[211,5911,288],{"class":217},[211,5913,5905],{"class":279},[211,5915,2387],{"class":217},[211,5917,1385],{"class":217},[211,5919,1607],{"class":275},[211,5921,1488],{"class":217},[211,5923,2003],{"class":1604},[211,5925,2006],{"class":1363},[211,5927,5870],{"class":279},[211,5929,288],{"class":217},[211,5931,5932],{"class":279},"pathname\n",[211,5934,5935,5938],{"class":213,"line":1566},[211,5936,5937],{"class":1363},"    return",[211,5939,5940],{"class":279}," url\n",[211,5942,5943,5945,5947],{"class":213,"line":1586},[211,5944,4230],{"class":217},[211,5946,5142],{"class":1363},[211,5948,237],{"class":217},[211,5950,5951,5953,5956,5958,5960,5962,5964,5966,5968,5970,5972,5974,5976,5978,5980,5982,5984],{"class":213,"line":1595},[211,5952,5937],{"class":1363},[211,5954,5955],{"class":279}," url",[211,5957,288],{"class":217},[211,5959,2121],{"class":1474},[211,5961,1477],{"class":1604},[211,5963,1488],{"class":217},[211,5965,1607],{"class":275},[211,5967,1488],{"class":217},[211,5969,2003],{"class":1604},[211,5971,3541],{"class":217},[211,5973,5955],{"class":279},[211,5975,3559],{"class":217},[211,5977,1922],{"class":217},[211,5979,1607],{"class":275},[211,5981,1928],{"class":217},[211,5983,1434],{"class":279},[211,5985,1934],{"class":217},[211,5987,5988],{"class":213,"line":1640},[211,5989,318],{"class":217},[211,5991,5992],{"class":213,"line":1676},[211,5993,324],{"class":217},[211,5995,5996],{"class":213,"line":1712},[211,5997,475],{"emptyLinePlaceholder":474},[211,5999,6000],{"class":213,"line":1761},[211,6001,6002],{"class":1343},"// When building citations from Vertex API response, normalize each one before adding to the map:\n",[211,6004,6005,6007,6010,6012,6014,6016,6019,6021,6023,6025,6028,6030,6032,6034,6036],{"class":213,"line":1797},[211,6006,1465],{"class":227},[211,6008,6009],{"class":279}," rawLink ",[211,6011,1471],{"class":217},[211,6013,4683],{"class":279},[211,6015,288],{"class":217},[211,6017,6018],{"class":279},"uri ",[211,6020,4598],{"class":217},[211,6022,4683],{"class":279},[211,6024,288],{"class":217},[211,6026,6027],{"class":279},"documentMetadata",[211,6029,4004],{"class":217},[211,6031,6018],{"class":279},[211,6033,4598],{"class":217},[211,6035,1707],{"class":217},[211,6037,6038],{"class":1343},"  // e.g. \"https://.../blog/21.building-a-linkedin...\"\n",[211,6040,6041,6043,6046,6048,6050,6052,6054,6057],{"class":213,"line":1808},[211,6042,1465],{"class":227},[211,6044,6045],{"class":279}," cleanUrl ",[211,6047,1471],{"class":217},[211,6049,5658],{"class":1474},[211,6051,1477],{"class":279},[211,6053,5637],{"class":1474},[211,6055,6056],{"class":279},"(rawLink))  ",[211,6058,6059],{"class":1343},"// -> \"/blog/building-a-linkedin...\"\n",[211,6061,6062,6064,6067,6069,6071,6073,6075,6078,6080,6083,6085,6088,6090,6093,6095],{"class":213,"line":1813},[211,6063,1465],{"class":227},[211,6065,6066],{"class":279}," entry ",[211,6068,1471],{"class":217},[211,6070,1367],{"class":217},[211,6072,5955],{"class":1604},[211,6074,234],{"class":217},[211,6076,6077],{"class":279}," cleanUrl",[211,6079,1373],{"class":217},[211,6081,6082],{"class":1604}," title",[211,6084,234],{"class":217},[211,6086,6087],{"class":279}," title ",[211,6089,2480],{"class":217},[211,6091,6092],{"class":217}," undefined",[211,6094,1379],{"class":217},[211,6096,6097],{"class":1343},"  // use cleanUrl, not rawLink\n",[163,6099,6100,6101,6104,6105,6108],{},"Inside your ",[172,6102,6103],{},"extractCitations"," (or equivalent) loop, use ",[172,6106,6107],{},"cleanUrl"," for each entry. Do not pass the raw Vertex URI to the client.",[728,6110,6112],{"id":6111},"_2-handle-both-markdown-links-and-citation-markers","2. Handle both Markdown links and citation markers",[163,6114,6115,6116,6119,6120,6123,6124,6127,6128,6130],{},"The Answer API returns citation markers like ",[172,6117,6118],{},"[1]"," or ",[172,6121,6122],{},"[a]"," in the reply text. The LLM may also emit inline Markdown links ",[172,6125,6126],{},"[text](url)"," when it references a post. The reply renderer must handle both. If it only handles citation markers, you will see raw ",[172,6129,6126],{}," in the UI. Process Markdown links first, then citation markers, and escape all content before building links.",[728,6132,6134],{"id":6133},"_3-citation-url-format","3. Citation URL format",[163,6136,6137,6138,6141,6142,6145,6146,6149],{},"Citation URLs can be relative paths (e.g. ",[172,6139,6140],{},"/blog/some-post",") or absolute URLs (",[172,6143,6144],{},"https://...","). The server should normalize Vertex's raw URLs to whatever format your composable expects. If the composable only accepts absolute URLs for citations, the server must send absolute. If it accepts relative paths, send relative. The server and composable must agree or citation links will not render. For same-site links, relative paths work regardless of domain. The ",[172,6147,6148],{},"cleanContentPath"," helper strips number prefixes from paths used in Markdown links.",[202,6151,6153],{"className":3400,"code":6152,"language":3402,"meta":207,"style":207},"// app/composables/useAskNicoReplyHtml.ts\n// Requires: cleanContentPath from useContentSlug for relative paths in Markdown links\nimport { cleanContentPath } from './useContentSlug'\n\nfunction renderReplyMarkdownLinks(raw: string, citations?: Record\u003Cstring, { url: string, title?: string }>): string {\n  if (!raw) return ''\n  const escape = (s: string) =>\n    s.replace(/&/g, '&amp;').replace(/\u003C/g, '&lt;').replace(/>/g, '&gt;').replace(/\"/g, '&quot;').replace(/'/g, '&#39;')\n\n  const isSafeHref = (href: string): boolean => {\n    const t = href.trim()\n    if (/^\\s*javascript:/i.test(t)) return false\n    return (t.startsWith('/') && !t.startsWith('//')) || /^https?:\\/\\//i.test(t)\n  }\n\n  // 1. Markdown links [text](url) first\n  const linkRe = /\\[([^\\]]*)\\]\\(([^)]*)\\)/g\n  let lastIndex = 0\n  const parts: string[] = []\n  let m: RegExpExecArray | null\n  while ((m = linkRe.exec(raw)) !== null) {\n    parts.push(escape(raw.slice(lastIndex, m.index)))\n    const label = m[1] ?? ''\n    let url = (m[2] ?? '').trim()\n    if (isSafeHref(url)) {\n      if (url.startsWith('/')) url = cleanContentPath(url)\n      parts.push(`\u003Ca href=\"${escape(url)}\" target=\"_blank\" rel=\"noopener noreferrer\">${escape(label)}\u003C/a>`)\n    } else {\n      parts.push(escape(m[0]))\n    }\n    lastIndex = m.index + m[0].length\n  }\n  parts.push(escape(raw.slice(lastIndex)))\n  let html = parts.join('')\n\n  // 2. Citation markers [1], [a], [i, j]\n  const citationRe = /\\[([a-zA-Z0-9]+(?:\\s*,\\s*[a-zA-Z0-9]+)*)\\]/g\n  html = html.replace(citationRe, (match, refs: string) => {\n    const first = refs.split(',')[0]?.trim()?.toLowerCase()\n    if (!first || !citations) return ''\n    const cit = citations[first]\n    if (!cit || !cit.url || !/^https?:\\/\\//i.test(cit.url)) return ''\n    const titleAttr = cit.title ? ` title=\"${escape(cit.title)}\"` : ''\n    return `\u003Ca href=\"${escape(cit.url)}\" target=\"_blank\" rel=\"noopener noreferrer\"${titleAttr}>${escape(match)}\u003C/a>`\n  })\n  return html\n}\n",[172,6154,6155,6160,6165,6185,6189,6245,6261,6284,6415,6419,6446,6464,6501,6575,6579,6583,6588,6622,6635,6653,6668,6703,6743,6765,6796,6813,6848,6892,6900,6924,6928,6957,6961,6988,7009,7013,7018,7060,7099,7142,7166,7184,7243,7288,7334,7340,7347],{"__ignoreMap":207},[211,6156,6157],{"class":213,"line":214},[211,6158,6159],{"class":1343},"// app/composables/useAskNicoReplyHtml.ts\n",[211,6161,6162],{"class":213,"line":221},[211,6163,6164],{"class":1343},"// Requires: cleanContentPath from useContentSlug for relative paths in Markdown links\n",[211,6166,6167,6169,6171,6174,6176,6178,6180,6183],{"class":213,"line":240},[211,6168,1364],{"class":1363},[211,6170,1367],{"class":217},[211,6172,6173],{"class":279}," cleanContentPath",[211,6175,1379],{"class":217},[211,6177,1382],{"class":1363},[211,6179,1385],{"class":217},[211,6181,6182],{"class":275},"./useContentSlug",[211,6184,1391],{"class":217},[211,6186,6187],{"class":213,"line":260},[211,6188,475],{"emptyLinePlaceholder":474},[211,6190,6191,6193,6196,6198,6201,6203,6205,6207,6209,6212,6215,6217,6220,6222,6224,6226,6228,6230,6232,6234,6236,6238,6241,6243],{"class":213,"line":295},[211,6192,1569],{"class":227},[211,6194,6195],{"class":1474}," renderReplyMarkdownLinks",[211,6197,1477],{"class":217},[211,6199,6200],{"class":1578},"raw",[211,6202,234],{"class":217},[211,6204,4721],{"class":246},[211,6206,1373],{"class":217},[211,6208,4605],{"class":1578},[211,6210,6211],{"class":217},"?:",[211,6213,6214],{"class":246}," Record",[211,6216,4650],{"class":217},[211,6218,6219],{"class":246},"string",[211,6221,1373],{"class":217},[211,6223,1367],{"class":217},[211,6225,5955],{"class":1604},[211,6227,234],{"class":217},[211,6229,4721],{"class":246},[211,6231,1373],{"class":217},[211,6233,6082],{"class":1604},[211,6235,6211],{"class":217},[211,6237,4721],{"class":246},[211,6239,6240],{"class":217}," }>):",[211,6242,4721],{"class":246},[211,6244,237],{"class":217},[211,6246,6247,6249,6251,6253,6255,6257,6259],{"class":213,"line":315},[211,6248,1993],{"class":1363},[211,6250,1575],{"class":1604},[211,6252,1998],{"class":217},[211,6254,6200],{"class":279},[211,6256,2003],{"class":1604},[211,6258,2006],{"class":1363},[211,6260,4887],{"class":217},[211,6262,6263,6265,6268,6270,6272,6275,6277,6279,6281],{"class":213,"line":321},[211,6264,1842],{"class":227},[211,6266,6267],{"class":279}," escape",[211,6269,1848],{"class":217},[211,6271,1575],{"class":217},[211,6273,6274],{"class":1578},"s",[211,6276,234],{"class":217},[211,6278,4721],{"class":246},[211,6280,1581],{"class":217},[211,6282,6283],{"class":227}," =>\n",[211,6285,6286,6289,6291,6293,6295,6297,6300,6302,6304,6306,6308,6311,6313,6315,6317,6319,6321,6323,6325,6327,6329,6331,6333,6336,6338,6340,6342,6344,6346,6348,6350,6352,6354,6356,6358,6361,6363,6365,6367,6369,6371,6373,6375,6377,6379,6381,6383,6386,6388,6390,6392,6394,6396,6398,6400,6402,6404,6406,6408,6411,6413],{"class":213,"line":498},[211,6287,6288],{"class":279},"    s",[211,6290,288],{"class":217},[211,6292,1601],{"class":1474},[211,6294,1477],{"class":1604},[211,6296,1607],{"class":217},[211,6298,6299],{"class":275},"&",[211,6301,1607],{"class":217},[211,6303,1626],{"class":253},[211,6305,1373],{"class":217},[211,6307,1385],{"class":217},[211,6309,6310],{"class":275},"&amp;",[211,6312,1488],{"class":217},[211,6314,1581],{"class":1604},[211,6316,288],{"class":217},[211,6318,1601],{"class":1474},[211,6320,1477],{"class":1604},[211,6322,1607],{"class":217},[211,6324,4650],{"class":275},[211,6326,1607],{"class":217},[211,6328,1626],{"class":253},[211,6330,1373],{"class":217},[211,6332,1385],{"class":217},[211,6334,6335],{"class":275},"&lt;",[211,6337,1488],{"class":217},[211,6339,1581],{"class":1604},[211,6341,288],{"class":217},[211,6343,1601],{"class":1474},[211,6345,1477],{"class":1604},[211,6347,1607],{"class":217},[211,6349,5370],{"class":275},[211,6351,1607],{"class":217},[211,6353,1626],{"class":253},[211,6355,1373],{"class":217},[211,6357,1385],{"class":217},[211,6359,6360],{"class":275},"&gt;",[211,6362,1488],{"class":217},[211,6364,1581],{"class":1604},[211,6366,288],{"class":217},[211,6368,1601],{"class":1474},[211,6370,1477],{"class":1604},[211,6372,1607],{"class":217},[211,6374,231],{"class":275},[211,6376,1607],{"class":217},[211,6378,1626],{"class":253},[211,6380,1373],{"class":217},[211,6382,1385],{"class":217},[211,6384,6385],{"class":275},"&quot;",[211,6387,1488],{"class":217},[211,6389,1581],{"class":1604},[211,6391,288],{"class":217},[211,6393,1601],{"class":1474},[211,6395,1477],{"class":1604},[211,6397,1607],{"class":217},[211,6399,1488],{"class":275},[211,6401,1607],{"class":217},[211,6403,1626],{"class":253},[211,6405,1373],{"class":217},[211,6407,1385],{"class":217},[211,6409,6410],{"class":275},"&#39;",[211,6412,1488],{"class":217},[211,6414,501],{"class":1604},[211,6416,6417],{"class":213,"line":504},[211,6418,475],{"emptyLinePlaceholder":474},[211,6420,6421,6423,6426,6428,6430,6433,6435,6437,6439,6442,6444],{"class":213,"line":509},[211,6422,1842],{"class":227},[211,6424,6425],{"class":279}," isSafeHref",[211,6427,1848],{"class":217},[211,6429,1575],{"class":217},[211,6431,6432],{"class":1578},"href",[211,6434,234],{"class":217},[211,6436,4721],{"class":246},[211,6438,5669],{"class":217},[211,6440,6441],{"class":246}," boolean",[211,6443,2085],{"class":227},[211,6445,237],{"class":217},[211,6447,6448,6450,6453,6455,6458,6460,6462],{"class":213,"line":515},[211,6449,2287],{"class":227},[211,6451,6452],{"class":279}," t",[211,6454,1848],{"class":217},[211,6456,6457],{"class":279}," href",[211,6459,288],{"class":217},[211,6461,1802],{"class":1474},[211,6463,1805],{"class":1604},[211,6465,6466,6468,6470,6472,6474,6476,6478,6481,6483,6485,6487,6490,6492,6495,6497,6499],{"class":213,"line":1535},[211,6467,2338],{"class":1363},[211,6469,1575],{"class":1604},[211,6471,1607],{"class":217},[211,6473,1687],{"class":1363},[211,6475,1696],{"class":275},[211,6477,1616],{"class":217},[211,6479,6480],{"class":275},"javascript:",[211,6482,1607],{"class":217},[211,6484,5285],{"class":253},[211,6486,288],{"class":217},[211,6488,6489],{"class":1474},"test",[211,6491,1477],{"class":1604},[211,6493,6494],{"class":279},"t",[211,6496,2279],{"class":1604},[211,6498,2006],{"class":1363},[211,6500,5184],{"class":2273},[211,6502,6503,6505,6507,6509,6511,6513,6515,6517,6519,6521,6523,6525,6527,6529,6531,6533,6535,6537,6540,6542,6544,6546,6549,6551,6554,6556,6558,6561,6563,6565,6567,6569,6571,6573],{"class":213,"line":1561},[211,6504,5937],{"class":1363},[211,6506,1575],{"class":1604},[211,6508,6494],{"class":279},[211,6510,288],{"class":217},[211,6512,2121],{"class":1474},[211,6514,1477],{"class":1604},[211,6516,1488],{"class":217},[211,6518,1607],{"class":275},[211,6520,1488],{"class":217},[211,6522,2003],{"class":1604},[211,6524,2354],{"class":217},[211,6526,2114],{"class":217},[211,6528,6494],{"class":279},[211,6530,288],{"class":217},[211,6532,2121],{"class":1474},[211,6534,1477],{"class":1604},[211,6536,1488],{"class":217},[211,6538,6539],{"class":275},"//",[211,6541,1488],{"class":217},[211,6543,2279],{"class":1604},[211,6545,2480],{"class":217},[211,6547,6548],{"class":217}," /",[211,6550,1687],{"class":1363},[211,6552,6553],{"class":275},"https",[211,6555,3541],{"class":217},[211,6557,234],{"class":275},[211,6559,6560],{"class":279},"\\/\\/",[211,6562,1607],{"class":217},[211,6564,5285],{"class":253},[211,6566,288],{"class":217},[211,6568,6489],{"class":1474},[211,6570,1477],{"class":1604},[211,6572,6494],{"class":279},[211,6574,501],{"class":1604},[211,6576,6577],{"class":213,"line":1566},[211,6578,318],{"class":217},[211,6580,6581],{"class":213,"line":1586},[211,6582,475],{"emptyLinePlaceholder":474},[211,6584,6585],{"class":213,"line":1595},[211,6586,6587],{"class":1343},"  // 1. Markdown links [text](url) first\n",[211,6589,6590,6592,6595,6597,6599,6601,6603,6605,6607,6609,6611,6613,6615,6617,6619],{"class":213,"line":1640},[211,6591,1842],{"class":227},[211,6593,6594],{"class":279}," linkRe",[211,6596,1848],{"class":217},[211,6598,6548],{"class":217},[211,6600,1723],{"class":279},[211,6602,1613],{"class":217},[211,6604,1728],{"class":279},[211,6606,1981],{"class":217},[211,6608,1733],{"class":279},[211,6610,1613],{"class":217},[211,6612,1581],{"class":275},[211,6614,1981],{"class":217},[211,6616,1744],{"class":279},[211,6618,1607],{"class":217},[211,6620,6621],{"class":253},"g\n",[211,6623,6624,6627,6630,6632],{"class":213,"line":1676},[211,6625,6626],{"class":227},"  let",[211,6628,6629],{"class":279}," lastIndex",[211,6631,1848],{"class":217},[211,6633,6634],{"class":253}," 0\n",[211,6636,6637,6639,6642,6644,6646,6649,6651],{"class":213,"line":1712},[211,6638,1842],{"class":227},[211,6640,6641],{"class":279}," parts",[211,6643,234],{"class":217},[211,6645,4721],{"class":246},[211,6647,6648],{"class":1604},"[] ",[211,6650,1471],{"class":217},[211,6652,2239],{"class":1604},[211,6654,6655,6657,6659,6661,6664,6666],{"class":213,"line":1761},[211,6656,6626],{"class":227},[211,6658,3500],{"class":279},[211,6660,234],{"class":217},[211,6662,6663],{"class":246}," RegExpExecArray",[211,6665,4705],{"class":217},[211,6667,4013],{"class":246},[211,6669,6670,6673,6676,6678,6680,6682,6684,6687,6689,6691,6693,6696,6699,6701],{"class":213,"line":1797},[211,6671,6672],{"class":1363},"  while",[211,6674,6675],{"class":1604}," ((",[211,6677,3486],{"class":279},[211,6679,1848],{"class":217},[211,6681,6594],{"class":279},[211,6683,288],{"class":217},[211,6685,6686],{"class":1474},"exec",[211,6688,1477],{"class":1604},[211,6690,6200],{"class":279},[211,6692,2279],{"class":1604},[211,6694,6695],{"class":217},"!==",[211,6697,6698],{"class":217}," null",[211,6700,2003],{"class":1604},[211,6702,218],{"class":217},[211,6704,6705,6708,6710,6712,6714,6717,6719,6721,6723,6726,6728,6731,6733,6735,6737,6740],{"class":213,"line":1808},[211,6706,6707],{"class":279},"    parts",[211,6709,288],{"class":217},[211,6711,2409],{"class":1474},[211,6713,1477],{"class":1604},[211,6715,6716],{"class":1474},"escape",[211,6718,1477],{"class":1604},[211,6720,6200],{"class":279},[211,6722,288],{"class":217},[211,6724,6725],{"class":1474},"slice",[211,6727,1477],{"class":1604},[211,6729,6730],{"class":279},"lastIndex",[211,6732,1373],{"class":217},[211,6734,3500],{"class":279},[211,6736,288],{"class":217},[211,6738,6739],{"class":279},"index",[211,6741,6742],{"class":1604},")))\n",[211,6744,6745,6747,6750,6752,6754,6756,6758,6761,6763],{"class":213,"line":1813},[211,6746,2287],{"class":227},[211,6748,6749],{"class":279}," label",[211,6751,1848],{"class":217},[211,6753,3500],{"class":279},[211,6755,2050],{"class":1604},[211,6757,2053],{"class":253},[211,6759,6760],{"class":1604},"] ",[211,6762,4598],{"class":217},[211,6764,4887],{"class":217},[211,6766,6767,6770,6772,6774,6776,6778,6780,6782,6784,6786,6788,6790,6792,6794],{"class":213,"line":1818},[211,6768,6769],{"class":227},"    let",[211,6771,5955],{"class":279},[211,6773,1848],{"class":217},[211,6775,1575],{"class":1604},[211,6777,3486],{"class":279},[211,6779,2050],{"class":1604},[211,6781,2178],{"class":253},[211,6783,6760],{"class":1604},[211,6785,4598],{"class":217},[211,6787,1707],{"class":217},[211,6789,1581],{"class":1604},[211,6791,288],{"class":217},[211,6793,1802],{"class":1474},[211,6795,1805],{"class":1604},[211,6797,6798,6800,6802,6805,6807,6809,6811],{"class":213,"line":1839},[211,6799,2338],{"class":1363},[211,6801,1575],{"class":1604},[211,6803,6804],{"class":1474},"isSafeHref",[211,6806,1477],{"class":1604},[211,6808,1434],{"class":279},[211,6810,2279],{"class":1604},[211,6812,218],{"class":217},[211,6814,6815,6818,6820,6822,6824,6826,6828,6830,6832,6834,6836,6838,6840,6842,6844,6846],{"class":213,"line":1912},[211,6816,6817],{"class":1363},"      if",[211,6819,1575],{"class":1604},[211,6821,1434],{"class":279},[211,6823,288],{"class":217},[211,6825,2121],{"class":1474},[211,6827,1477],{"class":1604},[211,6829,1488],{"class":217},[211,6831,1607],{"class":275},[211,6833,1488],{"class":217},[211,6835,2279],{"class":1604},[211,6837,1434],{"class":279},[211,6839,1848],{"class":217},[211,6841,6173],{"class":1474},[211,6843,1477],{"class":1604},[211,6845,1434],{"class":279},[211,6847,501],{"class":1604},[211,6849,6850,6853,6855,6857,6859,6861,6864,6866,6868,6871,6873,6876,6878,6880,6883,6885,6888,6890],{"class":213,"line":1937},[211,6851,6852],{"class":279},"      parts",[211,6854,288],{"class":217},[211,6856,2409],{"class":1474},[211,6858,1477],{"class":1604},[211,6860,1772],{"class":217},[211,6862,6863],{"class":275},"\u003Ca href=\"",[211,6865,1928],{"class":217},[211,6867,6716],{"class":1474},[211,6869,6870],{"class":279},"(url)",[211,6872,2015],{"class":217},[211,6874,6875],{"class":275},"\" target=\"_blank\" rel=\"noopener noreferrer\">",[211,6877,1928],{"class":217},[211,6879,6716],{"class":1474},[211,6881,6882],{"class":279},"(label)",[211,6884,2015],{"class":217},[211,6886,6887],{"class":275},"\u003C/a>",[211,6889,1772],{"class":217},[211,6891,501],{"class":1604},[211,6893,6894,6896,6898],{"class":213,"line":1990},[211,6895,2435],{"class":217},[211,6897,2438],{"class":1363},[211,6899,237],{"class":217},[211,6901,6902,6904,6906,6908,6910,6912,6914,6916,6918,6921],{"class":213,"line":2038},[211,6903,6852],{"class":279},[211,6905,288],{"class":217},[211,6907,2409],{"class":1474},[211,6909,1477],{"class":1604},[211,6911,6716],{"class":1474},[211,6913,1477],{"class":1604},[211,6915,3486],{"class":279},[211,6917,2050],{"class":1604},[211,6919,6920],{"class":253},"0",[211,6922,6923],{"class":1604},"]))\n",[211,6925,6926],{"class":213,"line":2150},[211,6927,2537],{"class":217},[211,6929,6930,6933,6935,6937,6939,6941,6944,6946,6948,6950,6952,6954],{"class":213,"line":2192},[211,6931,6932],{"class":279},"    lastIndex",[211,6934,1848],{"class":217},[211,6936,3500],{"class":279},[211,6938,288],{"class":217},[211,6940,6739],{"class":279},[211,6942,6943],{"class":217}," +",[211,6945,3500],{"class":279},[211,6947,2050],{"class":1604},[211,6949,6920],{"class":253},[211,6951,2056],{"class":1604},[211,6953,288],{"class":217},[211,6955,6956],{"class":279},"length\n",[211,6958,6959],{"class":213,"line":2197},[211,6960,318],{"class":217},[211,6962,6963,6966,6968,6970,6972,6974,6976,6978,6980,6982,6984,6986],{"class":213,"line":2202},[211,6964,6965],{"class":279},"  parts",[211,6967,288],{"class":217},[211,6969,2409],{"class":1474},[211,6971,1477],{"class":1604},[211,6973,6716],{"class":1474},[211,6975,1477],{"class":1604},[211,6977,6200],{"class":279},[211,6979,288],{"class":217},[211,6981,6725],{"class":1474},[211,6983,1477],{"class":1604},[211,6985,6730],{"class":279},[211,6987,6742],{"class":1604},[211,6989,6990,6992,6995,6997,6999,7001,7003,7005,7007],{"class":213,"line":2229},[211,6991,6626],{"class":227},[211,6993,6994],{"class":279}," html",[211,6996,1848],{"class":217},[211,6998,6641],{"class":279},[211,7000,288],{"class":217},[211,7002,2137],{"class":1474},[211,7004,1477],{"class":1604},[211,7006,4743],{"class":217},[211,7008,501],{"class":1604},[211,7010,7011],{"class":213,"line":2242},[211,7012,475],{"emptyLinePlaceholder":474},[211,7014,7015],{"class":213,"line":2284},[211,7016,7017],{"class":1343},"  // 2. Citation markers [1], [a], [i, j]\n",[211,7019,7020,7022,7025,7027,7029,7031,7033,7036,7039,7041,7043,7046,7049,7051,7054,7056,7058],{"class":213,"line":2312},[211,7021,1842],{"class":227},[211,7023,7024],{"class":279}," citationRe",[211,7026,1848],{"class":217},[211,7028,6548],{"class":217},[211,7030,1723],{"class":279},[211,7032,1964],{"class":217},[211,7034,7035],{"class":275},"a-zA-Z0-9",[211,7037,7038],{"class":217},"]+(?:",[211,7040,1696],{"class":275},[211,7042,1616],{"class":217},[211,7044,7045],{"class":275},",\\s",[211,7047,7048],{"class":217},"*[",[211,7050,7035],{"class":275},[211,7052,7053],{"class":217},"]+)*)",[211,7055,1728],{"class":279},[211,7057,1607],{"class":217},[211,7059,6621],{"class":253},[211,7061,7062,7065,7067,7069,7071,7073,7075,7078,7080,7082,7084,7086,7089,7091,7093,7095,7097],{"class":213,"line":2335},[211,7063,7064],{"class":279},"  html",[211,7066,1848],{"class":217},[211,7068,6994],{"class":279},[211,7070,288],{"class":217},[211,7072,1601],{"class":1474},[211,7074,1477],{"class":1604},[211,7076,7077],{"class":279},"citationRe",[211,7079,1373],{"class":217},[211,7081,1575],{"class":217},[211,7083,1952],{"class":1578},[211,7085,1373],{"class":217},[211,7087,7088],{"class":1578}," refs",[211,7090,234],{"class":217},[211,7092,4721],{"class":246},[211,7094,1581],{"class":217},[211,7096,2085],{"class":227},[211,7098,237],{"class":217},[211,7100,7101,7103,7106,7108,7110,7112,7114,7116,7118,7120,7122,7125,7127,7129,7131,7133,7135,7137,7140],{"class":213,"line":2401},[211,7102,2287],{"class":227},[211,7104,7105],{"class":279}," first",[211,7107,1848],{"class":217},[211,7109,7088],{"class":279},[211,7111,288],{"class":217},[211,7113,2061],{"class":1474},[211,7115,1477],{"class":1604},[211,7117,1488],{"class":217},[211,7119,1373],{"class":275},[211,7121,1488],{"class":217},[211,7123,7124],{"class":1604},")[",[211,7126,6920],{"class":253},[211,7128,2056],{"class":1604},[211,7130,4004],{"class":217},[211,7132,1802],{"class":1474},[211,7134,3029],{"class":1604},[211,7136,4004],{"class":217},[211,7138,7139],{"class":1474},"toLowerCase",[211,7141,1805],{"class":1604},[211,7143,7144,7146,7148,7150,7153,7155,7157,7160,7162,7164],{"class":213,"line":2432},[211,7145,2338],{"class":1363},[211,7147,1575],{"class":1604},[211,7149,1998],{"class":217},[211,7151,7152],{"class":279},"first",[211,7154,2636],{"class":217},[211,7156,2114],{"class":217},[211,7158,7159],{"class":279},"citations",[211,7161,2003],{"class":1604},[211,7163,2006],{"class":1363},[211,7165,4887],{"class":217},[211,7167,7168,7170,7173,7175,7177,7179,7181],{"class":213,"line":2506},[211,7169,2287],{"class":227},[211,7171,7172],{"class":279}," cit",[211,7174,1848],{"class":217},[211,7176,4605],{"class":279},[211,7178,2050],{"class":1604},[211,7180,7152],{"class":279},[211,7182,7183],{"class":1604},"]\n",[211,7185,7186,7188,7190,7192,7195,7197,7199,7201,7203,7205,7207,7209,7211,7213,7215,7217,7219,7221,7223,7225,7227,7229,7231,7233,7235,7237,7239,7241],{"class":213,"line":2534},[211,7187,2338],{"class":1363},[211,7189,1575],{"class":1604},[211,7191,1998],{"class":217},[211,7193,7194],{"class":279},"cit",[211,7196,2636],{"class":217},[211,7198,2114],{"class":217},[211,7200,7194],{"class":279},[211,7202,288],{"class":217},[211,7204,1434],{"class":279},[211,7206,2636],{"class":217},[211,7208,2114],{"class":217},[211,7210,1607],{"class":217},[211,7212,1687],{"class":1363},[211,7214,6553],{"class":275},[211,7216,3541],{"class":217},[211,7218,234],{"class":275},[211,7220,6560],{"class":279},[211,7222,1607],{"class":217},[211,7224,5285],{"class":253},[211,7226,288],{"class":217},[211,7228,6489],{"class":1474},[211,7230,1477],{"class":1604},[211,7232,7194],{"class":279},[211,7234,288],{"class":217},[211,7236,1434],{"class":279},[211,7238,2279],{"class":1604},[211,7240,2006],{"class":1363},[211,7242,4887],{"class":217},[211,7244,7245,7247,7250,7252,7254,7256,7259,7261,7263,7266,7268,7270,7273,7275,7278,7280,7282,7284,7286],{"class":213,"line":2540},[211,7246,2287],{"class":227},[211,7248,7249],{"class":279}," titleAttr",[211,7251,1848],{"class":217},[211,7253,7172],{"class":279},[211,7255,288],{"class":217},[211,7257,7258],{"class":279},"title",[211,7260,5807],{"class":217},[211,7262,1922],{"class":217},[211,7264,7265],{"class":275}," title=\"",[211,7267,1928],{"class":217},[211,7269,6716],{"class":1474},[211,7271,7272],{"class":279},"(cit",[211,7274,288],{"class":217},[211,7276,7277],{"class":279},"title)",[211,7279,2015],{"class":217},[211,7281,231],{"class":275},[211,7283,1772],{"class":217},[211,7285,3559],{"class":217},[211,7287,4887],{"class":217},[211,7289,7290,7292,7294,7296,7298,7300,7302,7304,7307,7309,7312,7314,7317,7319,7321,7323,7325,7328,7330,7332],{"class":213,"line":2545},[211,7291,5937],{"class":1363},[211,7293,1922],{"class":217},[211,7295,6863],{"class":275},[211,7297,1928],{"class":217},[211,7299,6716],{"class":1474},[211,7301,7272],{"class":279},[211,7303,288],{"class":217},[211,7305,7306],{"class":279},"url)",[211,7308,2015],{"class":217},[211,7310,7311],{"class":275},"\" target=\"_blank\" rel=\"noopener noreferrer\"",[211,7313,1928],{"class":217},[211,7315,7316],{"class":279},"titleAttr",[211,7318,2015],{"class":217},[211,7320,5370],{"class":275},[211,7322,1928],{"class":217},[211,7324,6716],{"class":1474},[211,7326,7327],{"class":279},"(match)",[211,7329,2015],{"class":217},[211,7331,6887],{"class":275},[211,7333,3939],{"class":217},[211,7335,7336,7338],{"class":213,"line":2553},[211,7337,4230],{"class":217},[211,7339,501],{"class":1604},[211,7341,7342,7344],{"class":213,"line":2558},[211,7343,1589],{"class":1363},[211,7345,7346],{"class":279}," html\n",[211,7348,7349],{"class":213,"line":2563},[211,7350,324],{"class":217},[163,7352,7353,7354,7357],{},"In the template, use ",[172,7355,7356],{},"v-html"," with the result. Escape all user content first; the composable does that before building links.",[202,7359,7361],{"className":4641,"code":7360,"language":4643,"meta":207,"style":207},"\u003Cdiv v-html=\"renderReplyMarkdownLinks(msg.content, msg.citations)\" />\n",[172,7362,7363],{"__ignoreMap":207},[211,7364,7365,7367,7369,7372,7374,7376,7379,7382,7384,7386,7388,7391,7393,7396,7398],{"class":213,"line":214},[211,7366,4650],{"class":217},[211,7368,5222],{"class":1604},[211,7370,7371],{"class":227}," v-html",[211,7373,1471],{"class":217},[211,7375,231],{"class":217},[211,7377,7378],{"class":1474},"renderReplyMarkdownLinks",[211,7380,7381],{"class":279},"(msg",[211,7383,288],{"class":217},[211,7385,1554],{"class":279},[211,7387,1373],{"class":217},[211,7389,7390],{"class":279}," msg",[211,7392,288],{"class":217},[211,7394,7395],{"class":279},"citations)",[211,7397,231],{"class":217},[211,7399,5524],{"class":217},[186,7401,7403],{"id":7402},"cicd-and-auto-updating-from-github","CI/CD and Auto-Updating from GitHub",[163,7405,7406,7407,288],{},"The index needed to stay in sync with the blog. I set up a GitHub Action that runs the ingestion script and triggers a re-import every time content changes on ",[172,7408,3026],{},[163,7410,7411],{},"Add these GitHub secrets (Settings → Secrets and variables → Actions):",[7413,7414,7415,7428],"table",{},[7416,7417,7418],"thead",{},[7419,7420,7421,7425],"tr",{},[7422,7423,7424],"th",{},"Secret",[7422,7426,7427],{},"Description",[7429,7430,7431,7449,7458,7468],"tbody",{},[7419,7432,7433,7439],{},[7434,7435,7436],"td",{},[172,7437,7438],{},"ASK_NICO_GCP_SA_KEY",[7434,7440,7441,7442,1308,7445,7448],{},"JSON key of a service account with ",[197,7443,7444],{},"Storage Object Admin",[197,7446,7447],{},"Discovery Engine Admin"," roles",[7419,7450,7451,7455],{},[7434,7452,7453],{},[172,7454,3229],{},[7434,7456,7457],{},"Your GCP project number (not project ID)",[7419,7459,7460,7465],{},[7434,7461,7462],{},[172,7463,7464],{},"ASK_NICO_GCS_BUCKET",[7434,7466,7467],{},"The GCS bucket name where the ingestion script uploads files",[7419,7469,7470,7474],{},[7434,7471,7472],{},[172,7473,3241],{},[7434,7475,7476],{},"The data store ID from your Vertex AI Search app",[163,7478,7479,7480,7482,7483,7485],{},"The workflow runs on push to ",[172,7481,3026],{}," when ",[172,7484,1315],{},", the ingest script, or the workflow file itself changes:",[202,7487,7491],{"className":7488,"code":7489,"language":7490,"meta":207,"style":207},"language-yaml shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","# .github/workflows/ask-nico-ingest.yml\nname: Ask Nico - Content Ingest\non:\n  push:\n    branches: [main]\n    paths:\n      - 'content/**'\n      - 'scripts/ask-nico-ingest/**'\n      - '.github/workflows/ask-nico-ingest.yml'\n\njobs:\n  ingest:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: pnpm/action-setup@v4\n      - uses: actions/setup-node@v4\n        with:\n          node-version: '20'\n          cache: 'pnpm'\n      - run: pnpm install\n\n      - name: Authenticate to GCP\n        uses: google-github-actions/auth@v2\n        with:\n          credentials_json: ${{ secrets.ASK_NICO_GCP_SA_KEY }}\n\n      - name: Ingest content to GCS bucket\n        run: node scripts/ask-nico-ingest/index.mjs\n        env:\n          GCP_PROJECT: ${{ secrets.ASK_NICO_PROJECT_NUMBER }}\n          ASK_NICO_GCS_BUCKET: ${{ secrets.ASK_NICO_GCS_BUCKET }}\n\n      - name: Trigger Vertex AI Search Import\n        run: |\n          ACCESS_TOKEN=$(gcloud auth print-access-token)\n          curl -X POST \\\n            -H \"Authorization: Bearer $ACCESS_TOKEN\" \\\n            -H \"Content-Type: application/json\" \\\n            \"https://discoveryengine.googleapis.com/v1/projects/${{ secrets.ASK_NICO_PROJECT_NUMBER }}/locations/global/collections/default_collection/dataStores/${{ secrets.ASK_NICO_DATA_STORE }}/branches/0/documents:import\" \\\n            -d '{\n              \"gcsSource\": {\n                \"inputUris\": [\"gs://${{ secrets.ASK_NICO_GCS_BUCKET }}/ask-nico/**\"],\n                \"dataSchema\": \"content\"\n              },\n              \"reconciliationMode\": \"INCREMENTAL\"\n            }'\n","yaml",[172,7492,7493,7498,7507,7515,7522,7536,7543,7555,7566,7577,7581,7588,7595,7605,7612,7624,7635,7646,7653,7667,7681,7693,7697,7709,7719,7725,7735,7739,7750,7760,7767,7777,7787,7791,7802,7811,7816,7821,7826,7831,7836,7841,7846,7851,7856,7861,7866],{"__ignoreMap":207},[211,7494,7495],{"class":213,"line":214},[211,7496,7497],{"class":1343},"# .github/workflows/ask-nico-ingest.yml\n",[211,7499,7500,7502,7504],{"class":213,"line":221},[211,7501,2307],{"class":1604},[211,7503,234],{"class":217},[211,7505,7506],{"class":275}," Ask Nico - Content Ingest\n",[211,7508,7509,7512],{"class":213,"line":240},[211,7510,7511],{"class":2273},"on",[211,7513,7514],{"class":217},":\n",[211,7516,7517,7520],{"class":213,"line":260},[211,7518,7519],{"class":1604},"  push",[211,7521,7514],{"class":217},[211,7523,7524,7527,7529,7532,7534],{"class":213,"line":295},[211,7525,7526],{"class":1604},"    branches",[211,7528,234],{"class":217},[211,7530,7531],{"class":217}," [",[211,7533,3026],{"class":275},[211,7535,7183],{"class":217},[211,7537,7538,7541],{"class":213,"line":315},[211,7539,7540],{"class":1604},"    paths",[211,7542,7514],{"class":217},[211,7544,7545,7548,7550,7553],{"class":213,"line":321},[211,7546,7547],{"class":217},"      -",[211,7549,1385],{"class":217},[211,7551,7552],{"class":275},"content/**",[211,7554,1391],{"class":217},[211,7556,7557,7559,7561,7564],{"class":213,"line":498},[211,7558,7547],{"class":217},[211,7560,1385],{"class":217},[211,7562,7563],{"class":275},"scripts/ask-nico-ingest/**",[211,7565,1391],{"class":217},[211,7567,7568,7570,7572,7575],{"class":213,"line":504},[211,7569,7547],{"class":217},[211,7571,1385],{"class":217},[211,7573,7574],{"class":275},".github/workflows/ask-nico-ingest.yml",[211,7576,1391],{"class":217},[211,7578,7579],{"class":213,"line":509},[211,7580,475],{"emptyLinePlaceholder":474},[211,7582,7583,7586],{"class":213,"line":515},[211,7584,7585],{"class":1604},"jobs",[211,7587,7514],{"class":217},[211,7589,7590,7593],{"class":213,"line":1535},[211,7591,7592],{"class":1604},"  ingest",[211,7594,7514],{"class":217},[211,7596,7597,7600,7602],{"class":213,"line":1561},[211,7598,7599],{"class":1604},"    runs-on",[211,7601,234],{"class":217},[211,7603,7604],{"class":275}," ubuntu-latest\n",[211,7606,7607,7610],{"class":213,"line":1566},[211,7608,7609],{"class":1604},"    steps",[211,7611,7514],{"class":217},[211,7613,7614,7616,7619,7621],{"class":213,"line":1586},[211,7615,7547],{"class":217},[211,7617,7618],{"class":1604}," uses",[211,7620,234],{"class":217},[211,7622,7623],{"class":275}," actions/checkout@v4\n",[211,7625,7626,7628,7630,7632],{"class":213,"line":1595},[211,7627,7547],{"class":217},[211,7629,7618],{"class":1604},[211,7631,234],{"class":217},[211,7633,7634],{"class":275}," pnpm/action-setup@v4\n",[211,7636,7637,7639,7641,7643],{"class":213,"line":1640},[211,7638,7547],{"class":217},[211,7640,7618],{"class":1604},[211,7642,234],{"class":217},[211,7644,7645],{"class":275}," actions/setup-node@v4\n",[211,7647,7648,7651],{"class":213,"line":1676},[211,7649,7650],{"class":1604},"        with",[211,7652,7514],{"class":217},[211,7654,7655,7658,7660,7662,7665],{"class":213,"line":1712},[211,7656,7657],{"class":1604},"          node-version",[211,7659,234],{"class":217},[211,7661,1385],{"class":217},[211,7663,7664],{"class":275},"20",[211,7666,1391],{"class":217},[211,7668,7669,7672,7674,7676,7679],{"class":213,"line":1761},[211,7670,7671],{"class":1604},"          cache",[211,7673,234],{"class":217},[211,7675,1385],{"class":217},[211,7677,7678],{"class":275},"pnpm",[211,7680,1391],{"class":217},[211,7682,7683,7685,7688,7690],{"class":213,"line":1797},[211,7684,7547],{"class":217},[211,7686,7687],{"class":1604}," run",[211,7689,234],{"class":217},[211,7691,7692],{"class":275}," pnpm install\n",[211,7694,7695],{"class":213,"line":1808},[211,7696,475],{"emptyLinePlaceholder":474},[211,7698,7699,7701,7704,7706],{"class":213,"line":1813},[211,7700,7547],{"class":217},[211,7702,7703],{"class":1604}," name",[211,7705,234],{"class":217},[211,7707,7708],{"class":275}," Authenticate to GCP\n",[211,7710,7711,7714,7716],{"class":213,"line":1818},[211,7712,7713],{"class":1604},"        uses",[211,7715,234],{"class":217},[211,7717,7718],{"class":275}," google-github-actions/auth@v2\n",[211,7720,7721,7723],{"class":213,"line":1839},[211,7722,7650],{"class":1604},[211,7724,7514],{"class":217},[211,7726,7727,7730,7732],{"class":213,"line":1912},[211,7728,7729],{"class":1604},"          credentials_json",[211,7731,234],{"class":217},[211,7733,7734],{"class":275}," ${{ secrets.ASK_NICO_GCP_SA_KEY }}\n",[211,7736,7737],{"class":213,"line":1937},[211,7738,475],{"emptyLinePlaceholder":474},[211,7740,7741,7743,7745,7747],{"class":213,"line":1990},[211,7742,7547],{"class":217},[211,7744,7703],{"class":1604},[211,7746,234],{"class":217},[211,7748,7749],{"class":275}," Ingest content to GCS bucket\n",[211,7751,7752,7755,7757],{"class":213,"line":2038},[211,7753,7754],{"class":1604},"        run",[211,7756,234],{"class":217},[211,7758,7759],{"class":275}," node scripts/ask-nico-ingest/index.mjs\n",[211,7761,7762,7765],{"class":213,"line":2150},[211,7763,7764],{"class":1604},"        env",[211,7766,7514],{"class":217},[211,7768,7769,7772,7774],{"class":213,"line":2192},[211,7770,7771],{"class":1604},"          GCP_PROJECT",[211,7773,234],{"class":217},[211,7775,7776],{"class":275}," ${{ secrets.ASK_NICO_PROJECT_NUMBER }}\n",[211,7778,7779,7782,7784],{"class":213,"line":2197},[211,7780,7781],{"class":1604},"          ASK_NICO_GCS_BUCKET",[211,7783,234],{"class":217},[211,7785,7786],{"class":275}," ${{ secrets.ASK_NICO_GCS_BUCKET }}\n",[211,7788,7789],{"class":213,"line":2202},[211,7790,475],{"emptyLinePlaceholder":474},[211,7792,7793,7795,7797,7799],{"class":213,"line":2229},[211,7794,7547],{"class":217},[211,7796,7703],{"class":1604},[211,7798,234],{"class":217},[211,7800,7801],{"class":275}," Trigger Vertex AI Search Import\n",[211,7803,7804,7806,7808],{"class":213,"line":2242},[211,7805,7754],{"class":1604},[211,7807,234],{"class":217},[211,7809,7810],{"class":1363}," |\n",[211,7812,7813],{"class":213,"line":2284},[211,7814,7815],{"class":275},"          ACCESS_TOKEN=$(gcloud auth print-access-token)\n",[211,7817,7818],{"class":213,"line":2312},[211,7819,7820],{"class":275},"          curl -X POST \\\n",[211,7822,7823],{"class":213,"line":2335},[211,7824,7825],{"class":275},"            -H \"Authorization: Bearer $ACCESS_TOKEN\" \\\n",[211,7827,7828],{"class":213,"line":2401},[211,7829,7830],{"class":275},"            -H \"Content-Type: application/json\" \\\n",[211,7832,7833],{"class":213,"line":2432},[211,7834,7835],{"class":275},"            \"https://discoveryengine.googleapis.com/v1/projects/${{ secrets.ASK_NICO_PROJECT_NUMBER }}/locations/global/collections/default_collection/dataStores/${{ secrets.ASK_NICO_DATA_STORE }}/branches/0/documents:import\" \\\n",[211,7837,7838],{"class":213,"line":2506},[211,7839,7840],{"class":275},"            -d '{\n",[211,7842,7843],{"class":213,"line":2534},[211,7844,7845],{"class":275},"              \"gcsSource\": {\n",[211,7847,7848],{"class":213,"line":2540},[211,7849,7850],{"class":275},"                \"inputUris\": [\"gs://${{ secrets.ASK_NICO_GCS_BUCKET }}/ask-nico/**\"],\n",[211,7852,7853],{"class":213,"line":2545},[211,7854,7855],{"class":275},"                \"dataSchema\": \"content\"\n",[211,7857,7858],{"class":213,"line":2553},[211,7859,7860],{"class":275},"              },\n",[211,7862,7863],{"class":213,"line":2558},[211,7864,7865],{"class":275},"              \"reconciliationMode\": \"INCREMENTAL\"\n",[211,7867,7868],{"class":213,"line":2563},[211,7869,7870],{"class":275},"            }'\n",[163,7872,7873,7874,7876,7877,7879,7880,7883],{},"The \"Ingest content to GCS bucket\" step runs ",[172,7875,1331],{},". That script transforms my markdown into something the LLM can use. My blog is a collection of ",[172,7878,1307],{}," files; to an LLM, that's just more \"Data Sludge.\" I needed to clean it without losing metadata. If the AI doesn't know ",[589,7881,7882],{},"when"," I wrote something, it can't understand the temporal context of my technical shifts.",[163,7885,7886],{},"The script does three things:",[808,7888,7889,7895,7901],{},[365,7890,7891,7894],{},[197,7892,7893],{},"Strip the Noise",": Removes markdown syntax (bolding, links, etc.) to keep the token count low and the focus on the text.",[365,7896,7897,7900],{},[197,7898,7899],{},"Preserve the Markup",": I don't just dump the text. I preserve the YAML markup as \"Key: Value\" blocks at the top of each file. The LLM needs the date and tags to know if my take on a technology is current or a relic of 2024.",[365,7902,7903,7906,7907,7909],{},[197,7904,7905],{},"Inject the Source",": Every file gets a ",[172,7908,1319],{}," at the top so the RAG system knows exactly which page on my site an answer came from.",[163,7911,7912,7913,7915,7916,3128,7918,7921,7922,7925],{},"The result is a clean set of ",[172,7914,1323],{}," files uploaded to the GCS bucket, organized by their original folder structure (",[172,7917,3127],{},[172,7919,7920],{},"the-lab/",", etc.). The import API call tells Vertex AI Search to re-index from that path. ",[172,7923,7924],{},"reconciliationMode: INCREMENTAL"," means only changed files are processed; the rest of the index stays intact. No manual re-ingest required. If you get 403 on import, ensure the service account has Discovery Engine Admin; if you see 0 results, check the Activity tab in the Vertex AI Search console.",[163,7927,7928],{},[3092,7929],{"alt":7930,"className":7931,"src":7932},"How about a nice game of chess?",[3096],"https://storage.googleapis.com/nico-westerdale-images/blog/ask-nico-turning-my-static-blog-into-a-rag-powered-llm-chat/how-about-a-nice-game-of-chess.png",[186,7934,7936],{"id":7935},"so-whats-actually-happening-in-vertex","So What's Actually Happening in Vertex?",[163,7938,7939],{},"Under the covers, Vertex AI Search is doing the standard RAG pipeline. It parses each document, chunks it into smaller segments (so retrieval can return relevant passages, not whole files), and generates embeddings for each chunk. Embeddings are dense vector representations that capture semantic meaning; similar content maps to nearby points in a high-dimensional space. Those vectors get indexed in a vector database. When you ask a question, your query is embedded too, and the system runs a similarity search to find the chunks whose vectors are closest to the query vector. The top matches are then passed to Gemini as context for the answer. It's retrieval-augmented generation: retrieve first, then generate. No manual chunking or embedding code required; Vertex handles it.",[163,7941,7942],{},"So how is this different from passing the text of a document into an off-the-shelf LLM along with a query? LLMs have fixed context windows and no memory of your private content. You can't stuff a decade of blog posts into a single prompt; even if you could, the model would forget the middle. RAG is a catch-all term for \"retrieve relevant bits, then generate,\" and implementations vary wildly. The key difference is that you're not asking the model to remember everything. You're asking it to answer from a curated slice of your documents or data, fetched at query time. The retrieval step does the heavy lifting; the LLM just synthesizes what it's given. That keeps responses grounded and avoids the hallucination drift that comes from relying on the model's training alone.",[163,7944,7945],{},"The alternative is to train or fine-tune a model on your content. That's more work: you need a curated dataset, compute, and a pipeline to produce a new model artifact. The change is permanent; the model weights are altered. Once trained, the model has internalized your data. RAG is the opposite. The model stays the same. You're not changing what it knows; you're changing what you give it at inference time. Update your documents, re-index, and the next query sees the new content. No retraining. For a blog that grows over time, that's the right trade-off.",[186,7947,7949],{"id":7948},"wrapping-up","Wrapping Up",[163,7951,7952,7953,7955],{},"If you have a static site or a pile of markdown, you can do this too. I opted for some additional orchestration and code to integrate the outputs, but the cool part is that the core RAG is very automated. You're really just interfacing with it like any AI. The plumbing is mostly GCP console clicks, a small ingest script, and a server route. ",[167,7954,1262],{"href":1261}," if you want to see it in action, or point your favorite LLM at this post and ask it to build it with you. Either way, your content deserves a voice, and I'm sure mine is already complaining about its user on Moltbook.",[1198,7957,7958],{},"html pre.shiki code .sHwdD, html code.shiki .sHwdD{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#546E7A;--shiki-default-font-style:italic;--shiki-dark:#676E95;--shiki-dark-font-style:italic}html pre.shiki code .s7zQu, html code.shiki .s7zQu{--shiki-light:#39ADB5;--shiki-light-font-style:italic;--shiki-default:#89DDFF;--shiki-default-font-style:italic;--shiki-dark:#89DDFF;--shiki-dark-font-style:italic}html pre.shiki code .sMK4o, html code.shiki .sMK4o{--shiki-light:#39ADB5;--shiki-default:#89DDFF;--shiki-dark:#89DDFF}html pre.shiki code .sTEyZ, html code.shiki .sTEyZ{--shiki-light:#90A4AE;--shiki-default:#EEFFFF;--shiki-dark:#BABED8}html pre.shiki code .sfazB, html code.shiki .sfazB{--shiki-light:#91B859;--shiki-default:#C3E88D;--shiki-dark:#C3E88D}html pre.shiki code .spNyl, html code.shiki .spNyl{--shiki-light:#9C3EDA;--shiki-default:#C792EA;--shiki-dark:#C792EA}html pre.shiki code .s2Zo4, html code.shiki .s2Zo4{--shiki-light:#6182B8;--shiki-default:#82AAFF;--shiki-dark:#82AAFF}html pre.shiki code .sHdIc, html code.shiki .sHdIc{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#EEFFFF;--shiki-default-font-style:italic;--shiki-dark:#BABED8;--shiki-dark-font-style:italic}html pre.shiki code .swJcz, html code.shiki .swJcz{--shiki-light:#E53935;--shiki-default:#F07178;--shiki-dark:#F07178}html pre.shiki code .sbssI, html code.shiki .sbssI{--shiki-light:#F76D47;--shiki-default:#F78C6C;--shiki-dark:#F78C6C}html pre.shiki code .sfNiH, html code.shiki .sfNiH{--shiki-light:#FF5370;--shiki-default:#FF9CAC;--shiki-dark:#FF9CAC}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sBMFI, html code.shiki .sBMFI{--shiki-light:#E2931D;--shiki-default:#FFCB6B;--shiki-dark:#FFCB6B}",{"title":207,"searchDepth":221,"depth":221,"links":7960},[7961,7962,7968,7969,7970,7975,7976,7977],{"id":1265,"depth":221,"text":1266},{"id":1286,"depth":221,"text":1287,"children":7963},[7964,7965,7966,7967],{"id":1293,"depth":240,"text":1294},{"id":3100,"depth":240,"text":3101},{"id":3307,"depth":240,"text":3308},{"id":3342,"depth":240,"text":3343},{"id":3356,"depth":221,"text":3357},{"id":3370,"depth":221,"text":3371},{"id":5619,"depth":221,"text":5620,"children":7971},[7972,7973,7974],{"id":5630,"depth":240,"text":5631},{"id":6111,"depth":240,"text":6112},{"id":6133,"depth":240,"text":6134},{"id":7402,"depth":221,"text":7403},{"id":7935,"depth":221,"text":7936},{"id":7948,"depth":221,"text":7949},"2026-03-01T00:00:00.000Z",{"src":7980},"https://storage.googleapis.com/nico-westerdale-images/blog/ask-nico-turning-my-static-blog-into-a-rag-powered-llm-chat/ask-nico-llm-ux.png",{},{"title":136,"description":1229},"kska8oZIBaLqLp9wWeCoEMea85lNWz509wRYdLMFqPE",{"id":7985,"title":132,"askNicoQuestion":7986,"authors":7987,"badge":7990,"body":7992,"date":8375,"description":8376,"extension":1221,"image":8377,"meta":8379,"navigation":474,"path":133,"seo":8380,"stem":134,"__hash__":8381},"posts/blog/22.why-llms-cant-play-chess.md","Why do LLMs fail at basic state tracking?",[7988],{"name":152,"to":153,"avatar":7989},{"src":155},[7991],{"label":158},{"type":160,"value":7993,"toc":8358},[7994,7997,8008,8013,8016,8023,8026,8030,8033,8036,8040,8043,8046,8050,8053,8057,8060,8067,8072,8079,8084,8088,8091,8105,8108,8112,8115,8118,8121,8128,8138,8142,8145,8148,8152,8164,8171,8181,8195,8199,8214,8223,8230,8234,8237,8240,8243,8246,8250,8259,8263,8272,8275,8279,8300,8315,8318,8321,8324,8328,8353],[163,7995,7996],{},"My son and I have been watching Gotham Chess' hilarious YouTube series on LLMs failing to play chess. ChatGPT versus Google's AI, ChatGPT versus Grok, the whole parade of language models squaring off on the chessboard. It's become a bit of a ritual for us. Not because the chess is good, but because LLMs are gloriously, predictably, terrible at playing the game.",[5222,7998,8001],{"className":7999},[8000],"responsive-video",[8002,8003],"iframe",{"src":8004,"title":8005,"frameBorder":6920,"allow":8006,"referrerPolicy":8007,"allowFullScreen":474},"https://www.youtube.com/embed/7g-jN3DTkWQ?si=UONn9XIhlWlpyjzr","YouTube video player","accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share","strict-origin-when-cross-origin",[163,8009,8010],{},[589,8011,8012],{},"Gotham Chess: Grok vs Copilot. Disaster ensues when Grok summons in a Rock. Copilot initially complains about the illegal move, but decides to let Grok make it anyway to exploit an attack by it's Queen, neglecting to realize that Grok's Bishop is somehow allowed to move sideways, setting up a flying attack from The Rook!",[163,8014,8015],{},"The LLMs trade off pieces like they're clearing out a garage sale. Rooks left hanging, knights blundered for nothing, queens handed over with a shrug. Then there are the illegal moves: castling when the king has already moved, sliding bishops through pawns, moving pieces that aren't even there. At some point you stop being surprised and start wondering what's actually going on inside those billions of vector parameters. The answer, it turns out, is not much. There's no real idea, no coherent picture of the board, no sense that the model understands it's playing a game with rules.",[163,8017,8018],{},[3092,8019],{"alt":8020,"className":8021,"src":8022},"Gotham Chess, flying Rook",[3096],"https://storage.googleapis.com/nico-westerdale-images/blog/why-llms-cant-play-chess/gotham-chess-flying-rook.png",[163,8024,8025],{},"Despite how advanced these models are, they can't even consistently follow the rules of the game. The \"let's get an LLM to do it\" refrain has become a reflexive reaction in startup business leadership and has graced a multitude of pitch decks put in front of would-be investors. Yes, there are times when LLMs are phenomenal at solving problems, but this is by no means true for all classes of problems. There is huge swathes of computer engineering where using an LLM is a gloriously terrible idea, just like using an LLM for playing chess is a gloriously terrible idea, despite Gotham's amusing content. This isn't a matter of scale or training data. It's baked into the fundamental ways that LLMs are built and operate.",[186,8027,8029],{"id":8028},"opening-gambit-a-facade-of-reasoning","Opening Gambit: A Facade of Reasoning",[163,8031,8032],{},"When you start watching LLMs square off in a chess match, things actually go quite well, and most LLMs are competent with the standard openings. This makes sense given that LLMs are trained on a huge swath of data, including millions of standard openings starting with e4 e5, Nf3 Nc6, the Italian, the Sicilian, the Ruy Lopez. These are well trodden paths with games going back centuries.",[163,8034,8035],{},"So what's happening? The model is mapping the current sequence of tokens onto a high dimensional vector space and sampling from the probability distribution that its training data has learned. Or, put simply: it's memorized the openings. If the board position is in the training set repeatedly, as most openings are, the LLM will be able to find it and recognize what other players often do next. When the position is well represented in that training data, the next move is usually the most commonly accepted best line or a reasonable alternative. It's important to realize that the LLM isn't reasoning about the board at all here. It's doing what it does best: pattern matching over a massive training set of data. For the repeating patterns in chess openings it will flawlessly pattern match and suggest the most reasonable move, and even tell you why it's doing it. Neato.",[186,8037,8039],{"id":8038},"midgame-clock-is-ticking-on-training-data","Midgame: Clock is Ticking on Training Data",[163,8041,8042],{},"Once the game moves past standard openings into a midgame, LLMs really struggle, and the dropoff in skill is sharp. There are more possible chess positions than any training set could ever contain, and the vast majority of midgame positions the model encounters are ones it has never seen before. The model is still mapping token sequences onto that high dimensional vector space and sampling from the probability distribution it learned. But now that distribution is sparse. The current position might be close to something in the training set, but it's not the same. Or, put simply: it hasn't memorized this position.",[163,8044,8045],{},"The LLM is guessing from similar looking positions, and the guesses are increasingly ungrounded. The model is still predicting the next token, but it's not maintaining an internal representation of the board. One missed capture, one inference slip, and the model's internal state diverges from reality. It thinks the rook is on a1 when it's actually on a8. That's when you get bishops sliding through pawns. The model isn't seeing blockers. It's sampling from a distribution that's no longer anchored to the actual board. Researchers call this state tracking failure. There's also pointer misbinding: when the model knows it needs to move a rook but grabs the wrong one. Two rooks, same token type, different contexts. The attention mechanism conflates them. The strategic intent of how to play is often correct, the LLM knows midgame theory as it's memorized books on the subject, but execution fails. Add in line of sight hallucinations, where the model slides a piece through another because it doesn't geometrically track blockers. Statistical probability overrides the constraints of the rules of the game. The problem is that pattern matching doesn't work when the pattern isn't there to match from.",[186,8047,8049],{"id":8048},"endgame-a-checkmate-with-reality","Endgame: A Checkmate with Reality",[163,8051,8052],{},"By the endgame, the collapse is total. The model's internal representation has drifted so far from the actual board that illegal moves become routine. Castling when the king has already moved. The LLM moves pieces that aren't there; the hilarious Gotham refrain of summoning in \"THE ROOK\" to sacrifice itself, yet only to reappear a few moves later. The probability distribution that the model is sampling from is no longer grounded in the training data at all. The position is so far from anything it's seen that the model is making wild guesses full of illegal moves. The vector space mapping returns almost nothing of value as the positions are so novel. The model keeps producing moves because that's what it's trained to do, but there's no coherent connection between those moves and the actual state of the board or even the rules of the game. This is because LLMs cannot apply a complex ruleset to a novel complex situation. They're just not built for it unless they have been heavily trained to pattern match on similar situations. Even then they are not reasoning, they're just checking to see if something has happened before, and mimicking it.",[186,8054,8056],{"id":8055},"llms-in-action","LLMs in Action",[163,8058,8059],{},"Let's see how ChatGPT and Gemini both handle the same position:",[163,8061,8062],{},[3092,8063],{"alt":8064,"className":8065,"src":8066},"Gemini thinks the Queen can move through the knight.",[3096],"https://storage.googleapis.com/nico-westerdale-images/blog/why-llms-cant-play-chess/gemini-fails-playing-chess.png",[163,8068,8069],{},[589,8070,8071],{},"Gemini suggests the white Bishop should take the black Pawn on e6, stating incorrectly that the black Bishop on e7 is being attacked. If white's Bishop is taken by the black Pawn on f7, Gemini suggests that the Queen should teleport over the white Knight to capture the black Knight on c6, stating it's undefended, when it is in fact defended by the Pawn on b7. This suicidal Queen move is also somehow a check to black's well defended King.",[163,8073,8074],{},[3092,8075],{"alt":8076,"className":8077,"src":8078},"ChatGPT can't see a pawn.",[3096],"https://storage.googleapis.com/nico-westerdale-images/blog/why-llms-cant-play-chess/chatgpt-fails-playing-chess.png",[163,8080,8081],{},[589,8082,8083],{},"ChatGPT makes the same move, and is so confident about sacrificing its white Bishop to check the black King, it marks the move with an exclamation mark, denoting that it's a great move. It neglects to see that it's not actually a check due to the black Pawn on f7, which can promptly take the white Bishop. Its line of thinking then suggests a counterattack to the black Rook taking the sacrificed Bishop by the white Queen teleporting over the white Knight to c4, or the Queen moving to g6, where the black Queen can promptly take it.",[186,8085,8087],{"id":8086},"computational-complexity","Computational Complexity",[163,8089,8090],{},"Let's understand the sheer size of the game of chess. Despite just 64 squares and 32 pieces, the number of possible games is huge. Claude Shannon estimated there are roughly 10^120 possible chess games, that's 1 followed by 120 zeros. For comparison, the observable universe contains about 10^80 atoms, that's significantly smaller. The number of possible chess games is so mindbogglingly massive that it would be impossible to build a computer to store every single game even if we were doing it at the atomic level. So, put simply, we can't \"solve\" chess. We would never have the computational power to deterministically map every single possible game and position and always determine the best board position. This is why both human and computer players have to do four things:",[808,8092,8093,8096,8099,8102],{},[365,8094,8095],{},"Memorize the best thinking for common lines",[365,8097,8098],{},"Create strategies that work in general and pattern match them to the current board position",[365,8100,8101],{},"Look ahead to determine which move will put the player in a higher probability of winning",[365,8103,8104],{},"Guess",[163,8106,8107],{},"As we've seen, LLMs are okay at 1, bad at 2 and just awful at 3 and get worse at 4 the further from the starting position the game progresses.",[186,8109,8111],{"id":8110},"stockfish-the-grandmasters-approach","Stockfish: The Grandmaster's Approach",[163,8113,8114],{},"Stockfish is the world's most advanced computer chess engine ever produced and far outclasses humans and LLMs alike. It does all the things that LLMs cannot. It maintains an explicit representation of the board. Every piece, every square, updated deterministically after every move. The engine has a move generator that produces only legal moves. It applies the rules of chess directly. Castling, en passant, piece movement: all encoded as logic, not approximated from training data.",[163,8116,8117],{},"For look ahead, Stockfish uses minimax search with alpha beta pruning. That is to say it explores a tree of possible moves, evaluates positions at the leaves, and backs the scores up to choose the best line. It can't search the whole tree, nobody can, but it searches millions of positions per second and cuts branches that can't possibly affect the outcome. This prunes the probability tree to more targeted results. The search is the opposite of pattern matching; it's actual computation. The engine also uses opening books for common lines and endgame tablebases for solved positions, but the midgame is pure search and evaluation.",[163,8119,8120],{},"Modern Stockfish adds in a small neural network for position evaluation. The key difference from an LLM: a neural net takes the board state as structured input and outputs a single score. It doesn't generate moves. It evaluates the board and outputs a score indicating whether the position is better or worse for one side. The search algorithm does the reasoning. The network just answers \"how good is this position?\" The combined approach in how Stockfish is built is the right architecture for the problem of playing chess well.",[163,8122,8123],{},[3092,8124],{"alt":8125,"className":8126,"src":8127},"Stockfish NNUE Layers.",[3096],"https://storage.googleapis.com/nico-westerdale-images/blog/why-llms-cant-play-chess/stockfish-NNUE-layers.png",[163,8129,8130],{},[589,8131,8132,8133,288],{},"Stockfish's Neural Net layers in action. Image from the ",[167,8134,8137],{"href":8135,"rel":8136},"https://www.chessprogramming.org/Stockfish_NNUE",[403],"Chessprogramming wiki",[186,8139,8141],{"id":8140},"wont-llms-just-get-better","Won't LLMs just get better?",[163,8143,8144],{},"Stockfish's Elo (its numeric chess rating) is around 3900. For context, Magnus Carlsen, arguably the world's strongest grandmaster of all time, is ranked around 2880. Chess.com ranks me around 1200 as a somewhat passable intermediate player. LLMs rank anywhere from 500 for standard models like Claude Opus 4.5 to over 1000 or so for the best thinking models like Gemini 3 Pro and GPT-5.1. However, even the highly ranked models routinely produce illegal moves.",[163,8146,8147],{},"The thinking models do better because they get a \"thinking budget.\" Instead of outputting the move immediately, they generate hundreds or thousands of tokens of internal reasoning first. They can write out the board state, list candidate moves, check for legality, and correct themselves before committing. It's verification through extended chain of thought. They catch their own mistakes in the scratch pad before the final answer. That's why legality rates jump from abysmal to 99% or higher with thinking models. But here's the ceiling: they're still not searching. Stockfish explores millions of positions per second, evaluates each one, and backs scores up a tree. The LLM is just pattern matching to a training set with more room to try and verify, poorly. The architecture doesn't change. No minimax. No alpha beta pruning. No explicit move generator that guarantees legality. The transformer is still predicting the next token based on a training set. It's doing a better job of checking its work, literally by throwing expensive compute at the problem, but it's not doing the work that Stockfish does. More compute, bigger models, longer thinking budgets will squeeze out incremental gains. They won't ever close the gap to 3900 Elo. The gap is architectural, not scalar.",[186,8149,8151],{"id":8150},"are-there-hybrid-chess-models","Are There Hybrid Chess Models?",[163,8153,8154,8155,8163],{},"Yes. The research points toward combining LLMs with the right tools rather than hoping the LLM will become the right tool. ",[167,8156,8162],{"href":8157,"target":8158,"rel":8159},"https://deepmind.google/research/publications/139455/","_blank",[8160,8161],"noopener","noreferrer","DeepMind's 2024 work"," on \"Mastering Board Games by External and Internal Planning with Language Models\" shows two approaches. In external search, the LLM guides Monte Carlo Tree Search rollouts and evaluations. In internal search, the model generates an in context linearized tree of potential futures before choosing a move. Both rely on pretraining the model on chess domain knowledge, which minimizes hallucinations and improves state prediction. The result: Grandmaster level performance, with a search budget per move comparable to human grandmasters. The LLM isn't doing the search alone. It's guiding it, or approximating it in context. The search does the heavy lifting.",[163,8165,8166],{},[3092,8167],{"alt":8168,"className":8169,"src":8170},"Mastering Board Games by External and Internal Planning with Language Models",[3096],"https://storage.googleapis.com/nico-westerdale-images/blog/why-llms-cant-play-chess/minmax-chess-llm.png",[163,8172,8173],{},[589,8174,8175,8176,8180],{},"External search in action from ",[167,8177,8179],{"href":8157,"rel":8178},[403],"Google DeepMind",", evaluating board positions.",[163,8182,521,8183,8188,8189,8194],{},[167,8184,8187],{"href":8185,"target":8158,"rel":8186},"https://arxiv.org/abs/2512.01992",[8160,8161],"LLM CHESS benchmark"," (December 2025) evaluates over 50 models on win rates, move legality, and hallucinated actions. Top reasoning models are now tested against engines like Komodo Dragon for Elo anchoring, since they've saturated random opponent evaluations. The ",[167,8190,8193],{"href":8191,"target":8158,"rel":8192},"https://maxim-saplin.github.io/llm_chess/",[8160,8161],"public leaderboard"," tracks which models can actually finish a game without illegal moves. The takeaway: hybrids work when you add the missing architecture. LLM for language and high level strategy, engine or search for legality and tree exploration. You're not fixing the transformer. You're wiring it to the right tool.",[186,8196,8198],{"id":8197},"beyond-the-board-the-same-gambit","Beyond the Board: The Same Gambit",[163,8200,8201,8202,8207,8208,8213],{},"The same pattern is being generalized beyond chess. ",[167,8203,8206],{"href":8204,"target":8158,"rel":8205},"https://proceedings.mlr.press/v235/zhou24r.html",[8160,8161],"Language Agent Tree Search (LATS)",", from ICML 2024, uses Monte Carlo Tree Search with LLMs for reasoning, acting, and planning across programming, web navigation, and question answering. The LLM proposes actions; the environment (test results, simulators) provides feedback; the search explores which trajectories succeed. 92.7% pass@1 on HumanEval, 75.9 on WebShop. External verification is the key. Research on ",[167,8209,8212],{"href":8210,"target":8158,"rel":8211},"https://arxiv.org/abs/2509.02761",[8160,8161],"plan verification for LLM agents"," shows that external verification significantly outperforms self critique: \"significant performance gains with sound external verification\" versus \"significant performance collapse with self-critique.\" The LLM plans. Something else verifies. Agentic frameworks like AgentFlow and OctoTools use separate planner, executor, and verifier modules.",[163,8215,8216,8217,8222],{},"Anthropic's much lauded work on Claude planning and subagents fits the same mold. ",[167,8218,8221],{"href":8219,"target":8158,"rel":8220},"https://www.anthropic.com/engineering/built-multi-agent-research-system",[8160,8161],"Anthropic's multi-agent research system"," uses an orchestrator worker pattern: a lead agent plans and delegates to specialized subagents (Explore, Plan, general purpose) that run in parallel with their own context windows and tools. The Plan subagent researches the codebase before the main agent presents a plan. The Explore subagent does read-only discovery. Each subagent is a separate capacity. The lead agent doesn't do everything. It decomposes, delegates, and synthesizes. Their internal evals show 90% better performance than a single agent on complex research tasks. The architecture: one agent plans, many agents execute with specialized roles and tools. Same lesson. When the problem exceeds what a single context can hold, or when different steps need different capabilities, wire the LLM to the right structure. Subagents, external search, verification modules. The chess lesson applies: when the problem has strict constraints or exceeds single-agent capacity, wire the LLM to tools and structures that can verify or execute.",[163,8224,8225],{},[3092,8226],{"alt":8227,"className":8228,"src":8229},"Claude's multi-agent approach",[3096],"https://storage.googleapis.com/nico-westerdale-images/blog/why-llms-cant-play-chess/claude-multi-agent.png",[186,8231,8233],{"id":8232},"dont-blunder-your-software-project","Don't Blunder Your Software Project",[163,8235,8236],{},"Chess is a pure diagnostic. It exposes a class of problems where LLMs fail not because they're undertrained, but because the task fundamentally doesn't fit the architecture of where LLMs excel. Stateful, rule-bound, sequential reasoning with strict constraints. Sound familiar? That describes a lot of what we build. Configuration validation. Workflow engines. Financial calculations. Anything where the rules are absolute and one wrong step invalidates the whole chain. The size of the problem matters too; at big data scale, LLMs flounder. Adding in an orchestration layer doesn't fix it. Subagents, lead agents, delegation: they help when the problem is about capacity or parallelization, when a complex problem can be chunked into small unconnected tasks. They don't help when the core task is state tracking and rule application. The LLM is still the wrong architecture at the center. Adding more LLMs around it doesn't change that.",[163,8238,8239],{},"The knee-jerk \"let's just use an LLM\" refrain makes sense for plenty of tasks. Natural language to SQL, summarization, code completion, translation, drafting emails, document Q&A, brainstorming, first drafts, refactoring suggestions, test generation, documentation, chat interfaces, content moderation triage, the list goes on! These are problems where statistical approximation works. A slightly wrong suggestion is still incredibly useful if we are unbound by a tight ruleset. By comparison a slightly wrong chess move is illegal. A slightly wrong financial calculation is fraud. The problem class matters. When the output has to be exact, when the rules are rigid, when the state has to be tracked precisely across many steps, an LLM might be the wrong tool. Not because it's dumb. Because it's built for something else entirely.",[163,8241,8242],{},"I use LLMs every day. They're extraordinary for the right problems. But watching Gotham Chess tear into ChatGPT's queen blunders with my son, I'm reminded that \"AI\" isn't one thing. It's a collection of tools, each with strengths and limits. Knowing when to reach for an LLM and when to reach for a rules engine, or a state machine, or an actual chess engine, is the skill. Gotham's content comes from the mismatch between expectation and reality of some of mankind's most advanced technology failing so spectacularly. The engineering lesson is to avoid creating that mismatch in prod.",[8244,8245],"hr",{},[186,8247,8249],{"id":8248},"update-orchestration-and-fine-tuning","Update: Orchestration and Fine-Tuning",[163,8251,8252,8253,8258],{},"Following a robust discussion about this article on ",[167,8254,8257],{"href":8255,"rel":8256},"https://www.reddit.com/r/chess/comments/1rer9qb/why_llms_cant_play_chess/",[403],"Reddit"," (50k+ views and counting), two technical counter-points stood out.",[728,8260,8262],{"id":8261},"_1-orchestration-vs-computation-stockfish-as-a-skill","1. Orchestration vs. Computation: \"Stockfish as a Skill\"",[163,8264,8265,8266,8271],{},"A common suggestion is that we can simply \"fix\" LLM chess by using the ",[167,8267,8270],{"href":8268,"rel":8269},"https://modelcontextprotocol.io/",[403],"Model Context Protocol (MCP)"," to give the model access to Stockfish as a tool. While this results in Stockfish level play, it confirms the architectural argument: the LLM isn't calculating chess; it is a high-level orchestrator.",[163,8273,8274],{},"In this setup, the LLM excels at Intent Recognition (understanding the user wants to play a move) and Tool Use (calling the engine), but it has completely offloaded the State Tracking and Logic to a symbolic system. It’s an example of why we should wire LLMs to specialized architectures rather than trying to force a Transformer to be a calculator.",[728,8276,8278],{"id":8277},"_2-the-uncanny-valley-of-fine-tuning","2. The Uncanny Valley of Fine-Tuning",[163,8280,8281,8282,8287,8288,8293,8294,8299],{},"It is possible to tune LLMs via supervised learning and get strong results far better than the off-the-shel LLMs. As Redditor ",[167,8283,8286],{"href":8284,"rel":8285},"https://www.reddit.com/r/chess/comments/1rer9qb/comment/o7er0r5/",[403],"Individual_Prior_446"," notes, models trained on millions of games from the ",[167,8289,8292],{"href":8290,"rel":8291},"https://database.nikonoel.fr/",[403],"Lichess Elite Database"," encode board-relevant patterns in their weights. ",[167,8295,8298],{"href":8296,"rel":8297},"https://arxiv.org/abs/2402.04494",[403],"Google DeepMind's ChessBench paper"," pushes this further: a 270M parameter transformer trained on 15 billion Stockfish-annotated action-values achieves 2895 Lichess blitz Elo against humans, that's grandmaster level, with no explicit search at test time. That's an astounding result. But here's the catch: it still requires Stockfish. The model is a student of Stockfish, not a replacement. Stockfish 16 sits at roughly 3800 Elo; the researchers' primary finding is that \"our largest model achieves good performance, it does not fully close the gap to Stockfish 16, and it is unclear whether further scaling would close this gap or whether other innovations are needed.\"",[163,8301,8302,8303,8308,8309,8314],{},"Smaller specialized models tell the same story. ",[167,8304,8307],{"href":8305,"rel":8306},"https://lazy-guy.github.io/chess-llama/",[403],"Chess-Llama"," (23M parameters, ~1400 Elo) only generates legal moves 99.1% of the time. Karvonen's ",[167,8310,8313],{"href":8311,"rel":8312},"https://github.com/adamkarvonen/chess_llm_interpretability",[403],"Chess-GPT"," research shows a linear probe can reconstruct a functional board state from internal activations with 99% accuracy, but reconstructability is not the same as existence. The probe shows that we can decode board-like structure; it does not prove the model \"has\" a board. There is no Board object, no piece array, no explicit state. Karvonen's model still fails on edge cases like pinned pieces, with an illegal move rate of 0.2% to 0.4%. Humans don't store chessboards as object-oriented Board() models either, and some of us are really good at chess, but these models reside in an \"Uncanny Valley\" of logic where the gaps matter’s.",[163,8316,8317],{},"The ChessBench paper surfaces the architectural limits directly.  It does not prove the model \"has\" a board, or \"sees\" the board, in any meaningful sense. There is no Board object, no piece array, no explicit state as one might have a \"Board()\" object in object-oriented code. Just weights and transient activation vectors. The model doesn't maintain a world model; it has distributed patterns that happen to correlate with one when we project our interpretation onto them. Now humans don't store chessboards in our brains as object-oriented Board() models either, and some of us are really good at chess.",[163,8319,8320],{},"The ChessBench paper surfaces the architectural limits directly, with some facsinating results. The model uses FEN (board state only), not full game history, so it cannot detect threefold repetition; the authors note it \"cannot plan ahead to minimize the risk of being forced into threefold repetition.\" Against bots, Elo drops by roughly 600 points compared to humans (2299 vs. 2895), in part because bots don't resign in lost positions and the model sometimes fails to convert overwhelming wins, paradoxically settling for draws when multiple moves map to the same value bin. A state-based predictor without search cannot guarantee it will commit to a single winning line. Supervised learning can approximate Stockfish, but it still needs Stockfish to create the training data; another 1000 points of ELo is a huge gap to cross.",[163,8322,8323],{},"I'd argue these models still reside in an \"Uncanny Valley\" of logic. Despite its specialized training, these models approach playing, representing and moving pieces in chess vastly differently from how Stockfish, a neural net based search algorithm would.",[728,8325,8327],{"id":8326},"emergent-vs-symbolic-models","Emergent vs. Symbolic Models",[163,8329,8330,8331,8334,8335,8338,8339,8344,8345,8352],{},"This highlights the fundamental difference between an ",[197,8332,8333],{},"Emergent World Model"," (probabilistic) and a ",[197,8336,8337],{},"Symbolic World Model"," (rule-bound, deterministic). My motivation for writing this article was to highlight that probabilistic LLMs are often not the most appropriate tool for the problem at hand. Yes, training can help, sometimes dramatically, but training requires access to large datasets to train from, and for some problems those may not be available; we may not know what \"good\" looks like in order to approximate it with supervised learning, and training is often difficult and expensive. There are also many times in software engineering when having a 100% accurate state based system is simply the best tool for the job. Could a future LLM trained on Stockfish data eclipse the 3800 Elo benchmark? I'd argue that with today's technology this is unlikely, even at scale. LLMs on their own can only mimic what the training set provides; how can the student eclipse the master if it cannot truly reason? Perhaps they someday will, as the Redditor ",[167,8340,8343],{"href":8341,"rel":8342},"https://www.reddit.com/r/chess/comments/1rer9qb/comment/o7fz1w3/",[403],"wonjaewoo"," noted, citing Rich Sutton's ",[167,8346,8349],{"href":8347,"rel":8348},"https://www.cs.utexas.edu/~eunsol/courses/data/bitter_lesson.pdf",[403],[589,8350,8351],{},"Bitter Lesson"," in AI research:",[350,8354,8355],{},[163,8356,8357],{},"\"The biggest lesson that can be read from 70 years of AI research is that general methods that leverage computation are ultimately the most effective, and by a large margin.\"",{"title":207,"searchDepth":221,"depth":221,"links":8359},[8360,8361,8362,8363,8364,8365,8366,8367,8368,8369,8370],{"id":8028,"depth":221,"text":8029},{"id":8038,"depth":221,"text":8039},{"id":8048,"depth":221,"text":8049},{"id":8055,"depth":221,"text":8056},{"id":8086,"depth":221,"text":8087},{"id":8110,"depth":221,"text":8111},{"id":8140,"depth":221,"text":8141},{"id":8150,"depth":221,"text":8151},{"id":8197,"depth":221,"text":8198},{"id":8232,"depth":221,"text":8233},{"id":8248,"depth":221,"text":8249,"children":8371},[8372,8373,8374],{"id":8261,"depth":240,"text":8262},{"id":8277,"depth":240,"text":8278},{"id":8326,"depth":240,"text":8327},"2026-02-26T00:00:00.000Z","LLMs' attempts to play chess are hilarious; they blunder queens and move illegally. Here's what this tells us about when to use LLMs to solve complex software engineering tasks, and when to absolutely not.",{"src":8378},"https://storage.googleapis.com/nico-westerdale-images/blog/why-llms-cant-play-chess/why-llms-cant-play-chess.png",{},{"title":132,"description":8376},"U8S7coRh57JHFt1Qpr2z0CGgEFBWAsDn58M-0yRCXpQ",{"id":8383,"title":128,"askNicoQuestion":8384,"authors":8385,"badge":8388,"body":8390,"date":9150,"description":9151,"extension":1221,"image":9152,"meta":9154,"navigation":474,"path":129,"seo":9155,"stem":130,"__hash__":9156},"posts/blog/21.building-a-linkedin-ml-persona-part-1-the-data-harvest.md","How did I build a digital clone for pennies?",[8386],{"name":152,"to":153,"avatar":8387},{"src":155},[8389],{"label":158},{"type":160,"value":8391,"toc":9135},[8392,8428,8431,8435,8442,8449,8463,8470,8473,8476,8480,8504,8510,8521,8531,8538,8542,8553,8556,8566,8580,8598,8602,8605,8631,8638,8650,8699,8710,8714,8733,8744,8747,8754,8760,8765,8770,8775,8826,8829,8836,8839,8854,8857,8862,8926,8930,8979,8982,8985,8989,9000,9006,9009,9024,9031,9049,9052,9055,9059,9065,9101,9110,9114,9117,9123,9132],[8393,8394,8395,8404,8409,8416,8419],"picture-and-text",{},[163,8396,8397,8398,8403],{},"A few weeks ago LinkedIn ",[167,8399,8402],{"href":8400,"rel":8401},"https://www.linkedin.com/help/linkedin/answer/a8059228",[403],"quietly updated its privacy policy"," to inform us that they are now using our personal data, articles, and likely every \"congrats on the new role!\" bland comment to train their own AI models.",[350,8405,8406],{},[163,8407,8408],{},"\"On November 3, 2025, we started to use some data from members in these regions to train content-generating AI models that enhance your experience and better connect our members to opportunities.\"",[163,8410,8411,8412,8415],{},"If you are also not especially overjoyed by the news, you can turn it off. Go to ",[197,8413,8414],{},"Settings & Privacy > Data Privacy > Data for Generative AI Improvement"," and toggle that switch to \"Off.\"",[163,8417,8418],{},"But this announcement gave me an idea.",[5212,8420,8421],{"v-slot:image":207},[163,8422,8423],{},[3092,8424],{"alt":8425,"className":8426,"src":8427},"Screenshot of the LinkedIn setting to opt-out of data usage for Generative AI Improvement.",[3096],"https://storage.googleapis.com/nico-westerdale-images/blog/building-a-linkedin-ml-persona-part-1-the-data-harvest/linkedin-data-for-generative-ai-improvement.png",[163,8429,8430],{},"I was wondering what exactly Ryan Roslansky, CEO of LinkedIn, is going to do with all our collective data. Like many people I have a love/hate relationship with LinkedIn, or put more precisely: tolerate/hate. The obsequious veneer of performative success and self-congratulatory promotion that defines the platform is decidedly \"mid,\" IMHO. This occasionally leads me to write posts that simply tell the truth or slightly poke fun at it all, but let's face it; I'm on there like everyone else because I'm distracted, bored, and I have some vague notion that it will help my career. What infuriates me is that I have actually made excellent connections, built friendships, and furthered my career all from LinkedIn. However, it's hard to see how the introduction of whatever LinkedIn's AI boffins can cook up would make the world's most unlikely social network's vibe actually any worse than it already is.",[186,8432,8434],{"id":8433},"my-linkedin-data","My LinkedIn Data",[163,8436,8437,8438,8441],{},"So I was thinking. If a massive corporation is mining my sardonic thoughts to train their AI, why shouldn't I use my own LinkedIn data to train my ",[589,8439,8440],{},"own"," AI model?",[163,8443,8444,8445,8448],{},"The posts that we all put out on various social networks are ",[589,8446,8447],{},"not"," our true selves; they're a carefully, or in some cases, not so carefully, manufactured facet of what we put out into the world. Now I'm not the most prolific online poster, but I wondered if I had enough posts to train an ML model on what I was putting out on LinkedIn, then what would it be like to interact with that model, to talk to it and have it talk back in my contrived LinkedIn voice?",[163,8450,8451,8452,8457,8458,288],{},"It's a concept straight out of Black Mirror's 2013 ",[167,8453,8456],{"href":8454,"rel":8455},"https://en.wikipedia.org/wiki/Be_Right_Back",[403],"\"Be Right Back\""," where a grieving widow reconstructs her boyfriend from his online history placed in an android body. If you've not seen the episode: spoiler, things did not turn out well. Over a decade later we have off-the-shelf ML cloud services that can do much of what is explored in that episode. Apart from human-realistic android robots; the best we can come up with there is slick consumer marketing and mannequin human control for anything as difficult as ",[167,8459,8462],{"href":8460,"rel":8461},"https://mashable.com/article/1x-neo-humanoid-robot-preorder",[403],"\"opening a door\"",[163,8464,8465,8466,8469],{},"To create this simulacrum of my corporate persona I first need the data. Also I can't afford a datacenter full of high-end NVIDIA GPUs, so this is going to require some serious corners to be cut to keep this affordable. This has to be cheap, scrappy, but feeding in a huge prompt into Chat-GPT or Gemini isn't really going to cut it either as I'm really trying to train a model to ",[589,8467,8468],{},"become me",", not just force feed a prompt with data into a giant LLM. Ideally we can do more with my LinkedIn model once it's up and running. Can it remember? Can it learn? Can it actually be useful for something? Would this be the one AGI that finally escapes the lab and becomes self-aware in a world-ending SkyNet event? If so will it post pithy memes while it enslaves humanity? I sure do hope it loves to troll, but I'm getting ahead of myself, and I've not built any of that yet.",[163,8471,8472],{},"So this is the story of how I built it. Or rather, how I started to build it, immediately hit a data engineering wall, and then engineered my way over it using synthetic data generation.",[163,8474,8475],{},"Welcome to Part 1: The Data Harvest.",[186,8477,8479],{"id":8478},"linkedin-digital-sludge","LinkedIn Digital Sludge",[8393,8481,8482,8495],{},[163,8483,8484,8485,8490,8491,8494],{},"Getting the raw data is actually the easy part. LinkedIn provides a tool to ",[167,8486,8489],{"href":8487,"rel":8488},"https://www.linkedin.com/help/linkedin/answer/a566336",[403],"download your data archive",". You request it and then annoyingly have to wait 24 hours, at which point they send you an email with a link back to the site where you can finally download a zip file of all the content LinkedIn has ascribed to you. Inside, buried among a hundred other files, is ",[172,8492,8493],{},"Shares.csv",", containing every post, reshare, and link you've ever subjected your network to.",[5212,8496,8497],{"v-slot:image":207},[163,8498,8499],{},[3092,8500],{"alt":8501,"className":8502,"src":8503},"Screenshot of the email from LinkedIn indicating the data archive is ready for download.",[3096],"https://storage.googleapis.com/nico-westerdale-images/blog/building-a-linkedin-ml-persona-part-1-the-data-harvest/linkedin-data-archive-ready.png",[163,8505,8506,8507,8509],{},"Example line from my ",[172,8508,8493],{}," file:",[202,8511,8515],{"className":8512,"code":8513,"language":8514,"meta":207,"style":207},"language-csv shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","\"\"Also why does LinkedIn have a \"\"Rewrite with AI\"\" option? Do I want to get this post turned into vapid platitudes devoid of any personality? Not a use case I'm interested in and I cant wait for 24 months time when all these ridiculous features are pulled without fanfare from every product we use as they must have AI because reasons.\",\"\",\"\",MEMBER_NETWORK\n","csv",[172,8516,8517],{"__ignoreMap":207},[211,8518,8519],{"class":213,"line":214},[211,8520,8513],{},[163,8522,521,8523,8526,8527,8530],{},[172,8524,8525],{},"ShareCommentary"," column, which contains the actual text of your posts, is not a clean string. Multi-paragraph posts are broken up by escaped quotation marks and newlines, scattered across multiple rows. The URLs you shared are in a completely different column (",[172,8528,8529],{},"SharedUrl","), disconnected from the commentary. Reshares of other people's content are indistinguishable from your own original thoughts. This is less of a dataset and more of a data sludge. Feeding this hot garbage into a language model wouldn't produce an insightful clone; it would produce a broken spambot that regurgitates fragments of half-remembered networking pleasantries.",[163,8532,8533,8534,8537],{},"We have data, but we need clean data. More importantly, we need ",[589,8535,8536],{},"structured"," data. The raw material isn't just messy; it's in the wrong shape entirely.",[186,8539,8541],{"id":8540},"choosing-the-model-to-train","Choosing the Model to Train",[163,8543,8544,8545,8548,8549,8552],{},"If you're not familiar, training an ML model alters it permanently. This is totally different from Uploading a .csv file to ChatGPT and asking it about the contents. The Big LLMs like Claude, ChatGPT and Gemini have trillions of parameters, and they are ",[589,8546,8547],{},"stateless",". That means they can't remember anything between uses. To get around this, these systems re-prompt the model with snippets of earlier conversations each time you interact, or use methods like RAG (Retrieval-Augmented Generation) to effectively force in data to that stateless model when you use it. If you're a user of AI you'll have doubtless experienced the phenomena of ",[589,8550,8551],{},"catastrophic forgetting",", where the stateless model completely loses the plot. Training is different, it fundamentally changes the matrix weightings of the model itself, permanently altering what it knows. Training is slow, expensive, and one of the reasons why NVIDIA's stock price is so high as it requires a lot of hardware.",[163,8554,8555],{},"Training comes in two flavors:",[163,8557,8558,8561,8562,8565],{},[197,8559,8560],{},"1. Pre-training","\nThis is where foundation models like ChatGPT, Claude, and Gemini are born. The goal is to give the model a broad, general understanding of language and facts. Trillions of data points scraped from the public internet are fed into a massive neural network, whose only job is to learn to predict the next word in a sequence. The result is a ",[197,8563,8564],{},"Base Model",", a repository of generalized knowledge that can form coherent sentences but has no specific purpose or personality.",[163,8567,8568,8571,8572,8575,8576,8579],{},[197,8569,8570],{},"2. Supervised Fine-Tuning (SFT)","\nThe goal here isn't to make a base model smarter; it's to make it ",[589,8573,8574],{},"useful"," by aligning it with a specific task or persona. Using a small, high-quality dataset we perform a targeted adjustment of the pre-trained weights. We are teaching the model ",[589,8577,8578],{},"how"," to behave by showing it explicit training examples.",[163,8581,8582,8583,8586,8587,8589,8590,8593,8594,8597],{},"The initial plan was to be ",[589,8584,8585],{},"brutally"," cheap, using Google's tiny ",[197,8588,199],{}," model which is the smallest I could find on Google Clouds Vertex AI. However, Gemma 2B came with a significant string attached: on Vertex AI, it requires ",[197,8591,8592],{},"full fine-tuning",". This means retraining all two billion of its parameters, a slow, computationally expensive process that creates an entirely new, model artifact for every training run. There was no option for more efficient methods like LoRA (Low-Rank Adaptation), which would just train a small, lightweight \"adapter\" on top of the base model. I discovered a better option on the platform: ",[197,8595,8596],{},"Llama 3.2 8B Instruct",". It's the perfect sweet spot: far more capable than Gemma 2B, but still small enough to keep the tuning and running costs low.",[186,8599,8601],{"id":8600},"instruction-tuning","Instruction Tuning",[163,8603,8604],{},"Fine-tuning an ML model isn't magic, it's pattern matching. If you want a model to \"speak Nico,\" you have to show it as many examples of what I want it to do, in my case examples of Nico speaking. My first attempts were exactly this, feeding the model with my LinkedIn posts formatted as simple text strings:",[202,8606,8608],{"className":204,"code":8607,"language":206,"meta":207,"style":207},"{\"text\": \"Latest News. Microsoft continues to push CoPilot at unwilling users in more profoundly inane ways.\"}\n",[172,8609,8610],{"__ignoreMap":207},[211,8611,8612,8614,8616,8618,8620,8622,8624,8627,8629],{"class":213,"line":214},[211,8613,1001],{"class":217},[211,8615,231],{"class":217},[211,8617,903],{"class":227},[211,8619,231],{"class":217},[211,8621,234],{"class":217},[211,8623,272],{"class":217},[211,8625,8626],{"class":275},"Latest News. Microsoft continues to push CoPilot at unwilling users in more profoundly inane ways.",[211,8628,231],{"class":217},[211,8630,324],{"class":217},[163,8632,8633,8634,8637],{},"Given that I only had 334 training data points in my set, the results were spotty at best. The model's base \"helpful assistant\" persona was still dominant. Sure, I could force the style with a very specific system prompt, but that's no different from using ChatGPT. The goal is to make the model ",[589,8635,8636],{},"become"," the persona. With such little data, the only way forward was to get more specific.",[163,8639,8640,8641,8643,8644,1308,8646,8649],{},"The solution is a more targeted technique called ",[197,8642,8601],{},". Instead of just showing the model a bucket of text and hoping it gets the vibe, you provide explicit ",[172,8645,5485],{},[172,8647,8648],{},"output"," pairs:",[202,8651,8653],{"className":204,"code":8652,"language":206,"meta":207,"style":207},"{\n  \"input\": \"Some kind of prompt or question.\",\n  \"output\": \"The desired response in my LinkedIn voice.\"\n}\n",[172,8654,8655,8659,8678,8695],{"__ignoreMap":207},[211,8656,8657],{"class":213,"line":214},[211,8658,218],{"class":217},[211,8660,8661,8663,8665,8667,8669,8671,8674,8676],{"class":213,"line":221},[211,8662,224],{"class":217},[211,8664,5485],{"class":227},[211,8666,231],{"class":217},[211,8668,234],{"class":217},[211,8670,272],{"class":217},[211,8672,8673],{"class":275},"Some kind of prompt or question.",[211,8675,231],{"class":217},[211,8677,257],{"class":217},[211,8679,8680,8682,8684,8686,8688,8690,8693],{"class":213,"line":240},[211,8681,224],{"class":217},[211,8683,8648],{"class":227},[211,8685,231],{"class":217},[211,8687,234],{"class":217},[211,8689,272],{"class":217},[211,8691,8692],{"class":275},"The desired response in my LinkedIn voice.",[211,8694,312],{"class":217},[211,8696,8697],{"class":213,"line":260},[211,8698,324],{"class":217},[163,8700,8701,8702,8705,8706,8709],{},"Looking at my 10 years of posts, I had hundreds of ",[172,8703,8704],{},"outputs",". I had zero ",[172,8707,8708],{},"inputs",", and zero interest in writing hundreds of fake questions for hundreds of real posts.",[186,8711,8713],{"id":8712},"synthetic-data-generation-the-depolisher","Synthetic Data Generation: The \"Depolisher\"",[163,8715,8716,8717,8720,8721,8726,8727,8732],{},"Writing 334 inputs to match my outputs isn't the most exhilarating way to spend an afternoon. Clearly a job for an AI. This approach is called ",[197,8718,8719],{},"Synthetic Data Generation",", and larger more operationalized versions of this are rapidly becoming the standard for training smaller, specialized models. The technique, often called \"self-instruct\" or \"distillation,\" uses a large, powerful \"teacher\" model to generate high-quality training data for a smaller \"student\" model. Research in the field has shown that ",[167,8722,8725],{"href":8723,"rel":8724},"https://arxiv.org/abs/2212.10560",[403],"\"Self-Instruct outperforms using existing public instruction datasets by a large margin\""," and this will doubtless see some ",[167,8728,8731],{"href":8729,"rel":8730},"https://research.google/blog/codeclm-aligning-language-models-with-tailored-synthetic-data/",[403],"commercial application from the likes of Google"," soon.",[163,8734,8735,8736,8739,8740,8743],{},"Based on this principle I built a small Python script I call ",[197,8737,8738],{},"The Depolisher",". It takes my high-quality, polished LinkedIn posts and feeds them, one by one, to Google's ",[197,8741,8742],{},"Gemini 2.5 Flash",". Gemini is smart, context-aware, and cheap and fast enough to process my entire history in five minutes.",[163,8745,8746],{},"The script runs in two modes, each generating a different flavor of synthetic data to solve the \"missing input\" problem.",[728,8748,8750,8751,1581],{"id":8749},"mode-1-reverse-engineering-the-prompt-instruct","Mode 1: Reverse-Engineering the Prompt (",[172,8752,8753],{},"instruct",[163,8755,8756,8757,288],{},"The first, most obvious approach is for a Q&A bot. I have the answers, so I asked Gemini to create the questions as if it was a contestant on ",[589,8758,8759],{},"Jeopardy!",[163,8761,8762],{},[197,8763,8764],{},"The Strategy:",[350,8766,8767],{},[163,8768,8769],{},"\"Read this text. Pretend it is the answer to a question. Write the question that would have plausibly resulted in this answer.\"",[163,8771,8772],{},[197,8773,8774],{},"The Result:",[202,8776,8778],{"className":204,"code":8777,"language":206,"meta":207,"style":207},"{\n  \"input_text\": \"Can you explain Test Driven Development?\",\n  \"output_text\": \"Test Driven Development. Push the code to production, test it, and that drives the development!\"\n}\n",[172,8779,8780,8784,8804,8822],{"__ignoreMap":207},[211,8781,8782],{"class":213,"line":214},[211,8783,218],{"class":217},[211,8785,8786,8788,8791,8793,8795,8797,8800,8802],{"class":213,"line":221},[211,8787,224],{"class":217},[211,8789,8790],{"class":227},"input_text",[211,8792,231],{"class":217},[211,8794,234],{"class":217},[211,8796,272],{"class":217},[211,8798,8799],{"class":275},"Can you explain Test Driven Development?",[211,8801,231],{"class":217},[211,8803,257],{"class":217},[211,8805,8806,8808,8811,8813,8815,8817,8820],{"class":213,"line":240},[211,8807,224],{"class":217},[211,8809,8810],{"class":227},"output_text",[211,8812,231],{"class":217},[211,8814,234],{"class":217},[211,8816,272],{"class":217},[211,8818,8819],{"class":275},"Test Driven Development. Push the code to production, test it, and that drives the development!",[211,8821,312],{"class":217},[211,8823,8824],{"class":213,"line":260},[211,8825,324],{"class":217},[163,8827,8828],{},"A perfect instruction pair, I tried it and again it was hard to tell if this was actually working as I could still see the base persona of the model training through. Perhaps I didn't have the training parameters set high enough, but I tried again, only this time got really really specific:",[728,8830,8832,8833,1581],{"id":8831},"mode-2-style-transfer-via-depolishing-rewrite","Mode 2: Style Transfer via \"Depolishing\" (",[172,8834,8835],{},"rewrite",[163,8837,8838],{},"I wanted a tool that could take a rough draft and rewrite it in my voice. A \"Nico-ifier.\" This is also a perfect task for a small model, essentially turning it into a translator, which is a very specific task I could train it to do.",[163,8840,8841,8842,8845,8846,8849,8850,8853],{},"To train the model I needed pairs where the ",[589,8843,8844],{},"Input"," was a bland, boring corporate draft, and the ",[589,8847,8848],{},"Output"," was my spicier final version. I needed to teach the model the ",[589,8851,8852],{},"delta"," between \"Generic Corporate Drone\" and \"Me.\"",[163,8855,8856],{},"So, I asked Gemini to surgically remove my soul.",[163,8858,8859],{},[197,8860,8861],{},"The \"Depolisher\" Prompt:",[202,8863,8865],{"className":455,"code":8864,"language":457,"meta":207,"style":207},"system_prompt = (\n    \"You are an expert editing assistant.\\n\"\n    \"Your job is to take highly polished, opinionated LinkedIn-style writing and rewrite it into a\\n\"\n    \"bland, neutral, professional draft that preserves ALL of the original information.\\n\"\n    \"Rules:\\n\"\n    \"- Preserve every concrete fact, claim, and example.\\n\"\n    \"- Keep the structure and length roughly the same (no summarizing or expanding).\\n\"\n    \"- Remove personal voice, jokes, sarcasm, rhetorical questions, and strong opinions.\\n\"\n    \"- Use straightforward business/professional language.\\n\"\n    \"- Do NOT mention AI, models, prompts, or that you are rewriting anything.\\n\"\n    \"- Output ONLY the rewritten text, with no explanations.\"\n)\n",[172,8866,8867,8872,8877,8882,8887,8892,8897,8902,8907,8912,8917,8922],{"__ignoreMap":207},[211,8868,8869],{"class":213,"line":214},[211,8870,8871],{},"system_prompt = (\n",[211,8873,8874],{"class":213,"line":221},[211,8875,8876],{},"    \"You are an expert editing assistant.\\n\"\n",[211,8878,8879],{"class":213,"line":240},[211,8880,8881],{},"    \"Your job is to take highly polished, opinionated LinkedIn-style writing and rewrite it into a\\n\"\n",[211,8883,8884],{"class":213,"line":260},[211,8885,8886],{},"    \"bland, neutral, professional draft that preserves ALL of the original information.\\n\"\n",[211,8888,8889],{"class":213,"line":295},[211,8890,8891],{},"    \"Rules:\\n\"\n",[211,8893,8894],{"class":213,"line":315},[211,8895,8896],{},"    \"- Preserve every concrete fact, claim, and example.\\n\"\n",[211,8898,8899],{"class":213,"line":321},[211,8900,8901],{},"    \"- Keep the structure and length roughly the same (no summarizing or expanding).\\n\"\n",[211,8903,8904],{"class":213,"line":498},[211,8905,8906],{},"    \"- Remove personal voice, jokes, sarcasm, rhetorical questions, and strong opinions.\\n\"\n",[211,8908,8909],{"class":213,"line":504},[211,8910,8911],{},"    \"- Use straightforward business/professional language.\\n\"\n",[211,8913,8914],{"class":213,"line":509},[211,8915,8916],{},"    \"- Do NOT mention AI, models, prompts, or that you are rewriting anything.\\n\"\n",[211,8918,8919],{"class":213,"line":515},[211,8920,8921],{},"    \"- Output ONLY the rewritten text, with no explanations.\"\n",[211,8923,8924],{"class":213,"line":1535},[211,8925,501],{},[163,8927,8928],{},[197,8929,8774],{},[202,8931,8933],{"className":204,"code":8932,"language":206,"meta":207,"style":207},"{\n  \"input_text\": \"The direct displacement of jobs by AI may not be the primary outcome; instead, a different set of operational challenges has materialized.\",\n  \"output_text\": \"Turns out AI isn't coming for our jobs, we are just in a different dystopian reality.\"\n}\n",[172,8934,8935,8939,8958,8975],{"__ignoreMap":207},[211,8936,8937],{"class":213,"line":214},[211,8938,218],{"class":217},[211,8940,8941,8943,8945,8947,8949,8951,8954,8956],{"class":213,"line":221},[211,8942,224],{"class":217},[211,8944,8790],{"class":227},[211,8946,231],{"class":217},[211,8948,234],{"class":217},[211,8950,272],{"class":217},[211,8952,8953],{"class":275},"The direct displacement of jobs by AI may not be the primary outcome; instead, a different set of operational challenges has materialized.",[211,8955,231],{"class":217},[211,8957,257],{"class":217},[211,8959,8960,8962,8964,8966,8968,8970,8973],{"class":213,"line":240},[211,8961,224],{"class":217},[211,8963,8810],{"class":227},[211,8965,231],{"class":217},[211,8967,234],{"class":217},[211,8969,272],{"class":217},[211,8971,8972],{"class":275},"Turns out AI isn't coming for our jobs, we are just in a different dystopian reality.",[211,8974,312],{"class":217},[211,8976,8977],{"class":213,"line":260},[211,8978,324],{"class":217},[163,8980,8981],{},"Perfect.",[163,8983,8984],{},"It turns out that generating \"bland, neutral, professional\" text is the default factory setting for most LLMs. By forcing a powerful model to generate the \"before\" state from my \"after\" state, I created a perfect dataset to train my model to do the exact opposite.",[186,8986,8988],{"id":8987},"the-fine-tuning-process","The Fine-Tuning Process",[163,8990,8991,8992,8586,8994,8996,8997,8999],{},"My initial plan was to be ",[589,8993,8585],{},[197,8995,199],{}," model on the Vertex AI platform. However, Gemma 2B came with a significant string attached: it required ",[197,8998,8592],{},". This means retraining all two billion of its parameters, a slow, computationally expensive process that creates an entirely new, monolithic model artifact for every training run. To make matters worse this full fine tuning requires orders of magnitude more data than I had available. There was no option for more efficient methods like LoRA (Low-Rank Adaptation), which would just train a small, lightweight \"adapter\" on top of the base model. A full fine tune works best with tens of thousands of training instructions, I only had a few hundred, which is perfect for LoRA.",[163,9001,9002,9003,9005],{},"While setting up this job, I discovered a better option on the platform: ",[197,9004,8596],{}," from Meta. Thanks Zuck. It's the perfect sweet spot: modern, far more capable at four times the size of Gemma 2B, but still small enough to keep the one-time tuning cost at less than $1.",[163,9007,9008],{},"The process was straightforward:",[808,9010,9011,9018,9021],{},[365,9012,9013,9014,9017],{},"Upload ",[172,9015,9016],{},"training_data_rewrite_v1.jsonl"," to a Google Cloud Storage bucket.",[365,9019,9020],{},"In Vertex AI, create a new supervised fine-tuning job.",[365,9022,9023],{},"Point the job at the data file and select the Llama 3.2 8B model.",[163,9025,9026],{},[3092,9027],{"alt":9028,"className":9029,"src":9030},"Screenshot of the Vertex AI interface for creating a tuned model, showing the selection of Llama 3.2 8B and other tuning settings.",[3096],"https://storage.googleapis.com/nico-westerdale-images/blog/building-a-linkedin-ml-persona-part-1-the-data-harvest/linkedin-ml-model-tuning.png",[163,9032,9033,9034,9037,9038,9041,9042,9044,9045,9048],{},"The most critical parameter here is the number of ",[197,9035,9036],{},"training steps",". With a tiny, high-quality dataset, the biggest risk is ",[197,9039,9040],{},"overtraining",". If you train for too long, the model doesn't just learn your ",[589,9043,1198],{},"; it memorizes your ",[589,9046,9047],{},"posts",". It becomes a brittle parrot, incapable of generalizing to new text. Train for too few steps, and the model's original \"helpful assistant\" persona will still bleed through, which is what I was seeing with the smaller Gemma model.",[163,9050,9051],{},"You have to find the sweet spot where you've aggressively overwritten the model's personality without catastrophically \"boiling its brain\" and making it forget how to form coherent sentences. After a bit of experimentation, the magic number seemed to be 8 epochs a learning rate of 0.0002. It was just enough to force the stylistic change without collapsing the model into a gibbering wreck.",[163,9053,9054],{},"Half an hour later, the job was done. I had a new model artifact in a storage bucket ready for deployment.",[186,9056,9058],{"id":9057},"the-pipeline","The Pipeline",[163,9060,9061,9062],{},"I wrapped all this logic into a small Python project, the ",[172,9063,9064],{},"linkedin-ai-persona-data-converter",[808,9066,9067,9075,9081,9092],{},[365,9068,9069,9072,9073,288],{},[197,9070,9071],{},"Ingest:"," Read the data sludge from ",[172,9074,8493],{},[365,9076,9077,9080],{},[197,9078,9079],{},"Filter:"," Heuristically discard reposts, comments, and low-effort posts (\u003C 50 characters).",[365,9082,9083,9086,9087,9089,9090,288],{},[197,9084,9085],{},"Transform:"," Hit the Gemini 2.5 Flash API with the \"Depolisher\" prompt to generate the synthetic ",[172,9088,5485],{}," for each ",[172,9091,8648],{},[365,9093,9094,9097,9098,9100],{},[197,9095,9096],{},"Format:"," Dump the pristine pairs into ",[172,9099,174],{}," files.",[163,9102,9103,9104,9109],{},"This is available on ",[167,9105,9108],{"href":9106,"rel":9107},"https://github.com/iconico/linkedin-ai-persona-data-converter",[403],"my github",", should you be interested.",[186,9111,9113],{"id":9112},"whats-next","What's Next?",[163,9115,9116],{},"So now I have a model trained on 334 perfect examples that map \"Bland Corporate Speak\" to \"Nico's Voice.\"",[163,9118,165,9119,9122],{},[167,9120,9121],{"href":141},"Part 2",", we move from data theory to using the model: Docker incantations, running this locally, and skirting some expensive traps to get this running for pennies on a Cloud Run instance.",[163,9124,9125,9126,9131],{},"That's live now. Oh and if you have thoughts about this please let me know by ",[167,9127,9130],{"href":9128,"rel":9129},"https://www.linkedin.com/posts/iconico_linkedin-is-training-its-ai-on-your-data-activity-7397258553821913088-7M2r",[403],"commenting on my LinkedIn post about this",". I will definitely be using all the comments to train my ML models.",[1198,9133,9134],{},"html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sMK4o, html code.shiki .sMK4o{--shiki-light:#39ADB5;--shiki-default:#89DDFF;--shiki-dark:#89DDFF}html pre.shiki code .spNyl, html code.shiki .spNyl{--shiki-light:#9C3EDA;--shiki-default:#C792EA;--shiki-dark:#C792EA}html pre.shiki code .sfazB, html code.shiki .sfazB{--shiki-light:#91B859;--shiki-default:#C3E88D;--shiki-dark:#C3E88D}",{"title":207,"searchDepth":221,"depth":221,"links":9136},[9137,9138,9139,9140,9141,9147,9148,9149],{"id":8433,"depth":221,"text":8434},{"id":8478,"depth":221,"text":8479},{"id":8540,"depth":221,"text":8541},{"id":8600,"depth":221,"text":8601},{"id":8712,"depth":221,"text":8713,"children":9142},[9143,9145],{"id":8749,"depth":240,"text":9144},"Mode 1: Reverse-Engineering the Prompt (instruct)",{"id":8831,"depth":240,"text":9146},"Mode 2: Style Transfer via \"Depolishing\" (rewrite)",{"id":8987,"depth":221,"text":8988},{"id":9057,"depth":221,"text":9058},{"id":9112,"depth":221,"text":9113},"2025-11-21T00:00:00.000Z","LinkedIn is using your data to train their AI. I decided to beat them to it. Here is how I used synthetic data generation to train a Llama 3.2 ML model on my own professional persona for pennies.",{"src":9153},"https://storage.googleapis.com/nico-westerdale-images/blog/building-a-linkedin-ml-persona-part-1-the-data-harvest/linkedin-gen-ai.png",{},{"title":128,"description":9151},"frEqwDpGvSy0iHk2q0rz1u9rDJskzdG7JeBfXxOgvRo",1789272918956]