[{"data":1,"prerenderedAt":1705},["ShallowReactive",2],{"writing-index":3},[4,1195,1358],{"id":5,"title":6,"body":7,"date":1184,"description":1185,"draft":1186,"extension":1187,"image":1188,"meta":1189,"navigation":293,"path":1190,"pillar":1191,"readingTime":119,"seo":1192,"stem":1193,"__hash__":1194},"writing\u002Fwriting\u002Fjev-first-steps.md","Jev: first steps",{"type":8,"value":9,"toc":1178},"minimark",[10,21,24,46,51,60,63,181,196,211,214,222,225,231,234,363,369,372,385,401,405,425,444,462,656,659,683,691,694,783,788,797,804,814,818,826,840,843,989,996,999,1005,1008,1011,1022,1025,1127,1131,1134,1137,1163,1166,1174],[11,12,13],"blockquote",{},[14,15,16,20],"p",{},[17,18,19],"strong",{},"Disclaimer:"," With this article I want to try out and show what TypeSafe Jev can do. It is not\na scientific evaluation and not a statement about public opinion. The comments are not\nrepresentative of the voting population.",[14,22,23],{},"As a basis, I collected 8640 reader comments written under articles about the food initiative on\nSwiss news portals. I used them to test Jev's three basic building blocks (primitives) one after\nthe other. Each step builds on the previous one:",[25,26,27,34,40],"ol",{},[28,29,30,33],"li",{},[17,31,32],{},"Noul",": Is the comment relevant at all?",[28,35,36,39],{},[17,37,38],{},"Choice",": Is the comment for or against the initiative?",[28,41,42,45],{},[17,43,44],{},"Score",": How well-reasoned is the comment, and how strong is its stance?",[47,48,50],"h2",{"id":49},"step-1-is-the-comment-relevant","Step 1: Is the comment relevant?",[14,52,53,59],{},[54,55,32],"a",{"href":56,"rel":57},"https:\u002F\u002Fdocs.typesafe.ai\u002Fprimitives\u002Fnoul",[58],"nofollow"," is the simplest primitive: a yes\u002Fno question. But\nJev doesn't just answer yes or no. It answers with a probability between 0 (definitely not) and 1\n(definitely yes).",[14,61,62],{},"This first filter alone already removes quite a lot of comments that are no longer of interest for\nfurther analysis, e.g. comments that only agree with the parent comment.",[64,65,70],"pre",{"className":66,"code":67,"language":68,"meta":69,"style":69},"language-python shiki shiki-themes github-light github-dark","QUESTION = Noul(\n    instructions=(\n        \"Does the comment address the Swiss food initiative ('Ernährungsinitiative' \u002F \"\n        \"'Initiative für sichere Ernährung') that is voted on in Switzerland on 27 September?\"\n    ),\n    criteria=NoulCriteria(\n        true=(\n            \"The comment refers to the initiative, the vote on it or its content: fewer animal \"\n            \"products, more plant-based food, food self-sufficiency, agriculture, 'forced veganism', \"\n            \"being patronised about what to eat. Also when the initiative is not named explicitly. \"\n            \"Also statements like 'reject both proposals'.\"\n        ),\n        false=(\n            \"The comment is only about the neutrality initiative, about parties or politicians \"\n            \"without any link to food, is a quarrel between commenters, or is about another topic.\"\n        ),\n    ),\n)\n","python","",[71,72,73,81,87,93,99,105,111,117,123,129,135,141,147,153,159,165,170,175],"code",{"__ignoreMap":69},[74,75,78],"span",{"class":76,"line":77},"line",1,[74,79,80],{},"QUESTION = Noul(\n",[74,82,84],{"class":76,"line":83},2,[74,85,86],{},"    instructions=(\n",[74,88,90],{"class":76,"line":89},3,[74,91,92],{},"        \"Does the comment address the Swiss food initiative ('Ernährungsinitiative' \u002F \"\n",[74,94,96],{"class":76,"line":95},4,[74,97,98],{},"        \"'Initiative für sichere Ernährung') that is voted on in Switzerland on 27 September?\"\n",[74,100,102],{"class":76,"line":101},5,[74,103,104],{},"    ),\n",[74,106,108],{"class":76,"line":107},6,[74,109,110],{},"    criteria=NoulCriteria(\n",[74,112,114],{"class":76,"line":113},7,[74,115,116],{},"        true=(\n",[74,118,120],{"class":76,"line":119},8,[74,121,122],{},"            \"The comment refers to the initiative, the vote on it or its content: fewer animal \"\n",[74,124,126],{"class":76,"line":125},9,[74,127,128],{},"            \"products, more plant-based food, food self-sufficiency, agriculture, 'forced veganism', \"\n",[74,130,132],{"class":76,"line":131},10,[74,133,134],{},"            \"being patronised about what to eat. Also when the initiative is not named explicitly. \"\n",[74,136,138],{"class":76,"line":137},11,[74,139,140],{},"            \"Also statements like 'reject both proposals'.\"\n",[74,142,144],{"class":76,"line":143},12,[74,145,146],{},"        ),\n",[74,148,150],{"class":76,"line":149},13,[74,151,152],{},"        false=(\n",[74,154,156],{"class":76,"line":155},14,[74,157,158],{},"            \"The comment is only about the neutrality initiative, about parties or politicians \"\n",[74,160,162],{"class":76,"line":161},15,[74,163,164],{},"            \"without any link to food, is a quarrel between commenters, or is about another topic.\"\n",[74,166,168],{"class":76,"line":167},16,[74,169,146],{},[74,171,173],{"class":76,"line":172},17,[74,174,104],{},[74,176,178],{"class":76,"line":177},18,[74,179,180],{},")\n",[14,182,183,184,187,188,191,192,195],{},"In my own code, I split the probability that Jev returns into three groups: from 0.8 ",[71,185,186],{},"yes",", up to\n0.2 ",[71,189,190],{},"no",", in between ",[71,193,194],{},"unsure",". You set these thresholds yourself; Jev only provides the\nprobability. This of course already lets you build interesting branches in the code: whether\nsomething can keep being handled automatically, or whether someone needs to be notified.",[64,197,199],{"className":66,"code":198,"language":68,"meta":69,"style":69},"p = response.answers[\"about_initiative\"].noul\nlabel = \"yes\" if p >= 0.8 else \"no\" if p \u003C= 0.2 else \"unsure\"\n",[71,200,201,206],{"__ignoreMap":69},[74,202,203],{"class":76,"line":77},[74,204,205],{},"p = response.answers[\"about_initiative\"].noul\n",[74,207,208],{"class":76,"line":83},[74,209,210],{},"label = \"yes\" if p >= 0.8 else \"no\" if p \u003C= 0.2 else \"unsure\"\n",[14,212,213],{},"The result for all 8640 comments:",[64,215,220],{"className":216,"code":218,"language":219,"meta":69},[217],"language-text","8640\n\nmedium_A    yes:  703  unsure: 1353  no: 1512\nmedium_B    yes: 1323  unsure: 1223  no:  415\nmedium_C    yes: 1047  unsure:  710  no:  354\n\ncondition         calls   time\u002Fcall   cost\u002Fcall  tokens\u002Fcall\ntypesafe_noul      8640       262ms   $0.000024          574\n\nTotal:    4,963,670 tokens, $0.2085, 142.7s (60.6 calls\u002Fs at concurrency 16)\nLatency:  median 258 ms, p95 304 ms, max 822 ms\n","text",[71,221,218],{"__ignoreMap":69},[14,223,224],{},"What stands out first: how fast and cheap the whole thing is. 8640 comments for 21 US cents. So\nnews portals could pre-sort their comments for practically no money.",[14,226,227,228,230],{},"For this analysis, I only continued working with the 3073 ",[71,229,186],{}," comments.",[14,232,233],{},"In a second run, I also wanted to know which arguments appear in the comments. For this, I defined\nseven more Noul questions, one per argument. All eight questions go to Jev in the same request, so\nit's still one call per comment.",[64,235,237],{"className":66,"code":236,"language":68,"meta":69,"style":69},"ARGUMENTS = {\n    \"paternalism\": \"the state dictating what people eat, 'forced veganism', loss of freedom of choice\",\n    \"prices\": \"food prices, cost of living or what people can afford\",\n    \"self_sufficiency\": \"food security, the self-sufficiency rate, imports of food or animal feed, \"\n                        \"resilience in a crisis\",\n    \"farmers\": \"farmers' incomes or livelihoods, livestock farming, grassland that is only usable for animals\",\n    \"environment\": \"climate, biodiversity, soil, water, pesticides or land use\",\n    \"animal_welfare\": \"how animals are kept or treated\",\n    \"health\": \"whether a diet with less meat or more plants is healthy\",\n}\n\nARGUMENT_QUESTIONS = {\n    f\"arg_{name}\": Noul(\n        instructions=(\n            f\"Does the comment use or discuss this argument about the Swiss food initiative: {topic}? \"\n            \"It does not matter whether the commenter is for or against the initiative.\"\n        ),\n        criteria=NoulCriteria(\n            true=f\"The comment itself makes a point about {topic}, also when rebutting someone else's point.\",\n            false=\"The comment does not touch this topic, or only mentions it in passing without making a point.\",\n        ),\n    )\n    for name, topic in ARGUMENTS.items()\n}\n",[71,238,239,244,249,254,259,264,269,274,279,284,289,295,300,305,310,315,320,324,329,335,341,346,352,358],{"__ignoreMap":69},[74,240,241],{"class":76,"line":77},[74,242,243],{},"ARGUMENTS = {\n",[74,245,246],{"class":76,"line":83},[74,247,248],{},"    \"paternalism\": \"the state dictating what people eat, 'forced veganism', loss of freedom of choice\",\n",[74,250,251],{"class":76,"line":89},[74,252,253],{},"    \"prices\": \"food prices, cost of living or what people can afford\",\n",[74,255,256],{"class":76,"line":95},[74,257,258],{},"    \"self_sufficiency\": \"food security, the self-sufficiency rate, imports of food or animal feed, \"\n",[74,260,261],{"class":76,"line":101},[74,262,263],{},"                        \"resilience in a crisis\",\n",[74,265,266],{"class":76,"line":107},[74,267,268],{},"    \"farmers\": \"farmers' incomes or livelihoods, livestock farming, grassland that is only usable for animals\",\n",[74,270,271],{"class":76,"line":113},[74,272,273],{},"    \"environment\": \"climate, biodiversity, soil, water, pesticides or land use\",\n",[74,275,276],{"class":76,"line":119},[74,277,278],{},"    \"animal_welfare\": \"how animals are kept or treated\",\n",[74,280,281],{"class":76,"line":125},[74,282,283],{},"    \"health\": \"whether a diet with less meat or more plants is healthy\",\n",[74,285,286],{"class":76,"line":131},[74,287,288],{},"}\n",[74,290,291],{"class":76,"line":137},[74,292,294],{"emptyLinePlaceholder":293},true,"\n",[74,296,297],{"class":76,"line":143},[74,298,299],{},"ARGUMENT_QUESTIONS = {\n",[74,301,302],{"class":76,"line":149},[74,303,304],{},"    f\"arg_{name}\": Noul(\n",[74,306,307],{"class":76,"line":155},[74,308,309],{},"        instructions=(\n",[74,311,312],{"class":76,"line":161},[74,313,314],{},"            f\"Does the comment use or discuss this argument about the Swiss food initiative: {topic}? \"\n",[74,316,317],{"class":76,"line":167},[74,318,319],{},"            \"It does not matter whether the commenter is for or against the initiative.\"\n",[74,321,322],{"class":76,"line":172},[74,323,146],{},[74,325,326],{"class":76,"line":177},[74,327,328],{},"        criteria=NoulCriteria(\n",[74,330,332],{"class":76,"line":331},19,[74,333,334],{},"            true=f\"The comment itself makes a point about {topic}, also when rebutting someone else's point.\",\n",[74,336,338],{"class":76,"line":337},20,[74,339,340],{},"            false=\"The comment does not touch this topic, or only mentions it in passing without making a point.\",\n",[74,342,344],{"class":76,"line":343},21,[74,345,146],{},[74,347,349],{"class":76,"line":348},22,[74,350,351],{},"    )\n",[74,353,355],{"class":76,"line":354},23,[74,356,357],{},"    for name, topic in ARGUMENTS.items()\n",[74,359,361],{"class":76,"line":360},24,[74,362,288],{},[64,364,367],{"className":365,"code":366,"language":219,"meta":69},[217],"8640 comments -> results\u002Frelevance_all.csv\n\nmedium_A   yes:  711  unsure: 1362  no: 1495\nmedium_B   yes: 1340  unsure: 1206  no:  415\nmedium_C   yes: 1035  unsure:  714  no:  362\n\ncondition           calls   time\u002Fcall   cost\u002Fcall  tokens\u002Fcall\ntypesafe_noul        8640       272ms   $0.000059         1401\n\nTotal:    12,108,950 tokens, $0.5086, 147.3s (58.6 calls\u002Fs at concurrency 16)\nLatency:  median 262 ms, p95 344 ms, max 1078 ms\n\nArguments in relevant comments (share and count per portal, p >= 0.5):\n                            medium_A        medium_B        medium_C\n  paternalism               43%  308        28%  370        26%  265\n  prices                    10%   74        14%  190        13%  135\n  self_sufficiency          38%  273        30%  397        38%  397\n  farmers                   16%  117        28%  381        23%  235\n  environment               25%  178        37%  491        31%  321\n  animal_welfare             6%   44         9%  118         8%   80\n  health                     7%   53        12%  156         6%   61\n  no argument: 687 of 3086 relevant comments\n",[71,368,366],{"__ignoreMap":69},[14,370,371],{},"This raises the cost from 21 to 51 US cents, because more tokens are processed per call (1401\ninstead of 574). The duration stays practically the same.",[14,373,374,375,378,379,384],{},"An excerpt from the result. ",[71,376,377],{},"jev_probability"," is the\n",[54,380,383],{"href":381,"rel":382},"https:\u002F\u002Fdocs.typesafe.ai\u002Fprimitives\u002Fnoul#response-structure",[58],"probability"," that Jev returns for the\nrelevance question:",[386,387,388,389,388,397],"figure",{},"\n  ",[390,391],"img",{"src":392,"alt":393,"width":394,"height":395,"loading":396},"\u002Fimages\u002Fwriting\u002Fjev-first-steps\u002Fnoul-result.png","A result table with the columns jev_probability, jev_label, one arg_ column per argument, arguments, input_tokens, output_tokens and duration_ms",2012,529,"lazy",[398,399,400],"figcaption",{},"One row per comment: the relevance probability, the derived label, and a probability for each of the seven arguments.",[47,402,404],{"id":403},"step-2-for-or-against","Step 2: For or against?",[14,406,407,408,412,413,416,417,420,421,424],{},"With the 3073 relevant comments, I moved on to the question: Is the comment for or against the\ninitiative? That's what ",[54,409,38],{"href":410,"rel":411},"https:\u002F\u002Fdocs.typesafe.ai\u002Fprimitives\u002Fchoice",[58]," is for. Instead of a\nyes\u002Fno question, you specify several options and Jev picks one of them. Here there are three:\n",[71,414,415],{},"in_favour",", ",[71,418,419],{},"against"," and ",[71,422,423],{},"neutral",".",[14,426,427,428,416,431,420,434,437,438,443],{},"Each option is described using the pattern ",[71,429,430],{},"what",[71,432,433],{},"not_for",[71,435,436],{},"examples",", which TypeSafe\nrecommends as best practice in the\n",[54,439,442],{"href":440,"rel":441},"https:\u002F\u002Fdocs.typesafe.ai\u002Fprimitives\u002Fchoice#structured-instructions-and-criteria",[58],"docs",":",[445,446,447,452,457],"ul",{},[28,448,449,451],{},[71,450,430],{},": when the option applies",[28,453,454,456],{},[71,455,433],{},": what looks similar but doesn't belong. This is where the difficult cases are, e.g.\nsomeone thinks animal welfare is important but still rejects the initiative.",[28,458,459,461],{},[71,460,436],{},": real comments as examples",[64,463,465],{"className":66,"code":464,"language":68,"meta":69,"style":69},"QUESTION = Choice(\n    instructions=(\n        \"What is the commenter's own stance on the Swiss food initiative ('Ernährungsinitiative' \u002F \"\n        \"'Initiative für sichere Ernährung') voted on in Switzerland on 27 September? \"\n        \"Judge the commenter's position, not the article's. If the comment is a reply, use the \"\n        \"replied-to comment only to understand what the commenter means: agreeing with an opponent \"\n        \"of the initiative means being against it.\"\n    ),\n    criteria={\n        \"in_favour\": {\n            \"what\": \"Supports the initiative, will vote yes, or defends it against criticism\",\n            \"not_for\": \"Sympathy for its goals (animal welfare, less meat) while rejecting the initiative itself\",\n            \"examples\": [\n                \"Böden schützen, weniger mit Gift arbeiten, Biodiversität schützen. Ja bitte!\",\n                \"Gerade deswegen ein pflanzlich-fettes JA!\",\n            ],\n        },\n        \"against\": {\n            \"what\": \"Rejects the initiative, will vote no, or attacks it (paternalism, costs, 'forced veganism', \"\n                    \"state intervention, unrealistic)\",\n            \"not_for\": \"Criticism of the no-campaign or of opponents' arguments\",\n            \"examples\": [\n                \"Ich lasse mich nicht gerne beim Essen auch noch bevormunden.\",\n                \"Wer weniger Fleisch essen möchte, kann das ohne diese Initiative tun.\",\n            ],\n        },\n        \"neutral\": {\n            \"what\": \"No recognisable position: asks a question, shares facts or polls, weighs both sides \"\n                    \"without deciding, or comments on the campaign itself\",\n            \"not_for\": \"Sarcasm or irony that clearly implies a position\",\n            \"examples\": [\n                \"Was genau würde sich für die Bauern ändern?\",\n                \"Laut Umfrage liegt das Nein-Lager vorne.\",\n            ],\n        },\n    },\n)\n",[71,466,467,472,476,481,486,491,496,501,505,510,515,520,525,530,535,540,545,550,555,560,565,570,574,579,584,589,594,600,606,612,618,623,629,635,640,645,651],{"__ignoreMap":69},[74,468,469],{"class":76,"line":77},[74,470,471],{},"QUESTION = Choice(\n",[74,473,474],{"class":76,"line":83},[74,475,86],{},[74,477,478],{"class":76,"line":89},[74,479,480],{},"        \"What is the commenter's own stance on the Swiss food initiative ('Ernährungsinitiative' \u002F \"\n",[74,482,483],{"class":76,"line":95},[74,484,485],{},"        \"'Initiative für sichere Ernährung') voted on in Switzerland on 27 September? \"\n",[74,487,488],{"class":76,"line":101},[74,489,490],{},"        \"Judge the commenter's position, not the article's. If the comment is a reply, use the \"\n",[74,492,493],{"class":76,"line":107},[74,494,495],{},"        \"replied-to comment only to understand what the commenter means: agreeing with an opponent \"\n",[74,497,498],{"class":76,"line":113},[74,499,500],{},"        \"of the initiative means being against it.\"\n",[74,502,503],{"class":76,"line":119},[74,504,104],{},[74,506,507],{"class":76,"line":125},[74,508,509],{},"    criteria={\n",[74,511,512],{"class":76,"line":131},[74,513,514],{},"        \"in_favour\": {\n",[74,516,517],{"class":76,"line":137},[74,518,519],{},"            \"what\": \"Supports the initiative, will vote yes, or defends it against criticism\",\n",[74,521,522],{"class":76,"line":143},[74,523,524],{},"            \"not_for\": \"Sympathy for its goals (animal welfare, less meat) while rejecting the initiative itself\",\n",[74,526,527],{"class":76,"line":149},[74,528,529],{},"            \"examples\": [\n",[74,531,532],{"class":76,"line":155},[74,533,534],{},"                \"Böden schützen, weniger mit Gift arbeiten, Biodiversität schützen. Ja bitte!\",\n",[74,536,537],{"class":76,"line":161},[74,538,539],{},"                \"Gerade deswegen ein pflanzlich-fettes JA!\",\n",[74,541,542],{"class":76,"line":167},[74,543,544],{},"            ],\n",[74,546,547],{"class":76,"line":172},[74,548,549],{},"        },\n",[74,551,552],{"class":76,"line":177},[74,553,554],{},"        \"against\": {\n",[74,556,557],{"class":76,"line":331},[74,558,559],{},"            \"what\": \"Rejects the initiative, will vote no, or attacks it (paternalism, costs, 'forced veganism', \"\n",[74,561,562],{"class":76,"line":337},[74,563,564],{},"                    \"state intervention, unrealistic)\",\n",[74,566,567],{"class":76,"line":343},[74,568,569],{},"            \"not_for\": \"Criticism of the no-campaign or of opponents' arguments\",\n",[74,571,572],{"class":76,"line":348},[74,573,529],{},[74,575,576],{"class":76,"line":354},[74,577,578],{},"                \"Ich lasse mich nicht gerne beim Essen auch noch bevormunden.\",\n",[74,580,581],{"class":76,"line":360},[74,582,583],{},"                \"Wer weniger Fleisch essen möchte, kann das ohne diese Initiative tun.\",\n",[74,585,587],{"class":76,"line":586},25,[74,588,544],{},[74,590,592],{"class":76,"line":591},26,[74,593,549],{},[74,595,597],{"class":76,"line":596},27,[74,598,599],{},"        \"neutral\": {\n",[74,601,603],{"class":76,"line":602},28,[74,604,605],{},"            \"what\": \"No recognisable position: asks a question, shares facts or polls, weighs both sides \"\n",[74,607,609],{"class":76,"line":608},29,[74,610,611],{},"                    \"without deciding, or comments on the campaign itself\",\n",[74,613,615],{"class":76,"line":614},30,[74,616,617],{},"            \"not_for\": \"Sarcasm or irony that clearly implies a position\",\n",[74,619,621],{"class":76,"line":620},31,[74,622,529],{},[74,624,626],{"class":76,"line":625},32,[74,627,628],{},"                \"Was genau würde sich für die Bauern ändern?\",\n",[74,630,632],{"class":76,"line":631},33,[74,633,634],{},"                \"Laut Umfrage liegt das Nein-Lager vorne.\",\n",[74,636,638],{"class":76,"line":637},34,[74,639,544],{},[74,641,643],{"class":76,"line":642},35,[74,644,549],{},[74,646,648],{"class":76,"line":647},36,[74,649,650],{},"    },\n",[74,652,654],{"class":76,"line":653},37,[74,655,180],{},[14,657,658],{},"What Jev returns is interesting. Not just the chosen option, but three things:",[25,660,661,667,673],{},[28,662,663,666],{},[71,664,665],{},"choice",": the option with the highest probability",[28,668,669,672],{},[71,670,671],{},"confidence",": how clear this answer is",[28,674,675,678,679,682],{},[71,676,677],{},"probabilities",": a probability for ",[17,680,681],{},"each"," option, including the ones not chosen",[14,684,685,686,420,688,690],{},"You can keep working with the third point in the code. For example, you could take into account\nevery option above 0.25. Or you could detect comments where ",[71,687,415],{},[71,689,419],{}," are both high\nand have a human look at them.",[14,692,693],{},"This is what the result looks like per comment:",[695,696,697,710],"table",{},[698,699,700],"thead",{},[701,702,703,707],"tr",{},[704,705,706],"th",{},"Column",[704,708,709],{},"Meaning",[711,712,713,724,740,750,760],"tbody",{},[701,714,715,721],{},[716,717,718],"td",{"data-label":706},[71,719,720],{},"relevance",[716,722,723],{"data-label":709},"How likely it is that the comment is about the food initiative at all, from 0 to 1.",[701,725,726,737],{},[716,727,728,416,731,416,734],{"data-label":706},[71,729,730],{},"p_in_favour",[71,732,733],{},"p_against",[71,735,736],{},"p_neutral",[716,738,739],{"data-label":709},"The probability Jev gives to each of the three options. Together they add up to about 1.",[701,741,742,747],{},[716,743,744],{"data-label":706},[71,745,746],{},"jev_choice",[716,748,749],{"data-label":709},"The option with the highest probability, i.e. Jev's answer.",[701,751,752,757],{},[716,753,754],{"data-label":706},[71,755,756],{},"jev_confidence",[716,758,759],{"data-label":709},"How clear this answer is, from 0 to 1. Close to 1 means the probability is almost entirely on one option. Low means it is spread across several.",[701,761,762,767],{},[716,763,764],{"data-label":706},[71,765,766],{},"stance",[716,768,769,770,773,774,776,777,779,780,782],{"data-label":709},"The ",[17,771,772],{},"final result",": ",[71,775,746],{}," if ",[71,778,756],{}," ≥ 0.7, otherwise ",[71,781,194],{},". This rule is in my code, not in Jev.",[14,784,785,786,424],{},"As with Noul, I decide in my own code when I trust the answer. If the confidence is below the\nthreshold, the comment is marked as ",[71,787,194],{},[64,789,791],{"className":66,"code":790,"language":68,"meta":69,"style":69},"stance = a.choice if a.confidence >= MIN_CONFIDENCE else \"unsure\"\n",[71,792,793],{"__ignoreMap":69},[74,794,795],{"class":76,"line":77},[74,796,790],{},[14,798,799,800,443],{},"TypeSafe writes about this in the ",[54,801,442],{"href":802,"rel":803},"https:\u002F\u002Fdocs.typesafe.ai\u002Fpatterns\u002Fconfidence-routing",[58],[11,805,806],{},[14,807,808,809,813],{},"One of TypeSafe's most powerful features is ",[54,810,671],{"href":811,"rel":812},"https:\u002F\u002Fdocs.typesafe.ai\u002Fconfidence",[58],". By\nbeing intentional with the way you gate decisions on confidence, you can build systems that are\nboth reliable and safe.",[47,815,817],{"id":816},"step-3-rating-the-arguments","Step 3: Rating the arguments",[14,819,820,821,825],{},"The third primitive is ",[54,822,44],{"href":823,"rel":824},"https:\u002F\u002Fdocs.typesafe.ai\u002Fprimitives\u002Fscore",[58],". Instead of options, you\nspecify a scale, here from 0 to 4, and describe each level in words. I used it to rate two things,\nboth in the same call:",[445,827,828,834],{},[28,829,830,833],{},[71,831,832],{},"argument_quality",": How well-reasoned is the comment? From \"just an insult or a slogan\" to\n\"several concrete arguments, numbers or sources\".",[28,835,836,839],{},[71,837,838],{},"stance_intensity",": How strong is the stance? From \"clearly against\" through \"no position\" to\n\"clearly in favour\".",[14,841,842],{},"This would be interesting for news portals, for example: sorting comments not just by likes or\ndate, but by how well-reasoned they are.",[64,844,846],{"className":66,"code":845,"language":68,"meta":69,"style":69},"QUESTIONS = {\n    \"argument_quality\": Score(\n        instructions=(\n            \"How well-argued is the comment's contribution to the debate about the Swiss food initiative \"\n            \"('Ernährungsinitiative')? Judge the reasoning, not whether you agree with it.\"\n        ),\n        criteria=[\n            \"Only an insult, slogan, emoji or joke without any content\",\n            \"States an opinion without giving any reason\",\n            \"Gives one general reason, e.g. paternalism, costs or health, without going into detail\",\n            \"Makes a concrete argument about what the initiative would change, e.g. self-sufficiency, \"\n            \"feed imports, food prices or farmers' incomes\",\n            \"Makes several concrete arguments, cites numbers or sources, or engages with counter-arguments\",\n        ],\n    ),\n    \"stance_intensity\": Score(\n        instructions=(\n            \"Where does the commenter stand on the Swiss food initiative ('Ernährungsinitiative')? \"\n            \"Judge the commenter's own position, not the article's. If the comment is a reply, use the \"\n            \"replied-to comment only to understand what the commenter means.\"\n        ),\n        criteria=[\n            \"Rejects the initiative outright, e.g. calls it extreme or dangerous or will definitely vote no\",\n            \"Has doubts or criticises parts of the initiative, but is not firmly against it\",\n            \"Takes no position, weighs both sides without deciding, or only asks or informs\",\n            \"Is sympathetic to the initiative or defends parts of it, but has reservations\",\n            \"Clearly supports the initiative, will definitely vote yes or defends it strongly\",\n        ],\n    ),\n}\n",[71,847,848,853,858,862,867,872,876,881,886,891,896,901,906,911,916,920,925,929,934,939,944,948,952,957,962,967,972,977,981,985],{"__ignoreMap":69},[74,849,850],{"class":76,"line":77},[74,851,852],{},"QUESTIONS = {\n",[74,854,855],{"class":76,"line":83},[74,856,857],{},"    \"argument_quality\": Score(\n",[74,859,860],{"class":76,"line":89},[74,861,309],{},[74,863,864],{"class":76,"line":95},[74,865,866],{},"            \"How well-argued is the comment's contribution to the debate about the Swiss food initiative \"\n",[74,868,869],{"class":76,"line":101},[74,870,871],{},"            \"('Ernährungsinitiative')? Judge the reasoning, not whether you agree with it.\"\n",[74,873,874],{"class":76,"line":107},[74,875,146],{},[74,877,878],{"class":76,"line":113},[74,879,880],{},"        criteria=[\n",[74,882,883],{"class":76,"line":119},[74,884,885],{},"            \"Only an insult, slogan, emoji or joke without any content\",\n",[74,887,888],{"class":76,"line":125},[74,889,890],{},"            \"States an opinion without giving any reason\",\n",[74,892,893],{"class":76,"line":131},[74,894,895],{},"            \"Gives one general reason, e.g. paternalism, costs or health, without going into detail\",\n",[74,897,898],{"class":76,"line":137},[74,899,900],{},"            \"Makes a concrete argument about what the initiative would change, e.g. self-sufficiency, \"\n",[74,902,903],{"class":76,"line":143},[74,904,905],{},"            \"feed imports, food prices or farmers' incomes\",\n",[74,907,908],{"class":76,"line":149},[74,909,910],{},"            \"Makes several concrete arguments, cites numbers or sources, or engages with counter-arguments\",\n",[74,912,913],{"class":76,"line":155},[74,914,915],{},"        ],\n",[74,917,918],{"class":76,"line":161},[74,919,104],{},[74,921,922],{"class":76,"line":167},[74,923,924],{},"    \"stance_intensity\": Score(\n",[74,926,927],{"class":76,"line":172},[74,928,309],{},[74,930,931],{"class":76,"line":177},[74,932,933],{},"            \"Where does the commenter stand on the Swiss food initiative ('Ernährungsinitiative')? \"\n",[74,935,936],{"class":76,"line":331},[74,937,938],{},"            \"Judge the commenter's own position, not the article's. If the comment is a reply, use the \"\n",[74,940,941],{"class":76,"line":337},[74,942,943],{},"            \"replied-to comment only to understand what the commenter means.\"\n",[74,945,946],{"class":76,"line":343},[74,947,146],{},[74,949,950],{"class":76,"line":348},[74,951,880],{},[74,953,954],{"class":76,"line":354},[74,955,956],{},"            \"Rejects the initiative outright, e.g. calls it extreme or dangerous or will definitely vote no\",\n",[74,958,959],{"class":76,"line":360},[74,960,961],{},"            \"Has doubts or criticises parts of the initiative, but is not firmly against it\",\n",[74,963,964],{"class":76,"line":586},[74,965,966],{},"            \"Takes no position, weighs both sides without deciding, or only asks or informs\",\n",[74,968,969],{"class":76,"line":591},[74,970,971],{},"            \"Is sympathetic to the initiative or defends parts of it, but has reservations\",\n",[74,973,974],{"class":76,"line":596},[74,975,976],{},"            \"Clearly supports the initiative, will definitely vote yes or defends it strongly\",\n",[74,978,979],{"class":76,"line":602},[74,980,915],{},[74,982,983],{"class":76,"line":608},[74,984,104],{},[74,986,987],{"class":76,"line":614},[74,988,288],{},[14,990,991,992,995],{},"For each question, Jev again returns a probability for each level, plus a confidence and a ",[71,993,994],{},"score",".\nThe score is the average of the levels, weighted by their probabilities. That's why there are also\nvalues like 2.92, and you can sort the comments finely instead of just putting them into five\nbuckets.",[14,997,998],{},"The result for the 3073 relevant comments. The first table shows how many comments end up at which\nquality level (0 to 4):",[64,1000,1003],{"className":1001,"code":1002,"language":219,"meta":69},[217],"3073 comments\n\nmedium_A     0:   23  1:   94  2:  251  3:  228  4:  107\nmedium_B     0:   17  1:  146  2:  453  3:  324  4:  383\nmedium_C     0:   22  1:  146  2:  292  3:  317  4:  270\n\ncondition           calls   time\u002Fcall   cost\u002Fcall  tokens\u002Fcall\ntypesafe_2x_score    3073       266ms   $0.000032          768\n\nTotal:    2,360,239 tokens, $0.0991, 51.5s (59.7 calls\u002Fs at concurrency 16)\nLatency:  median 260 ms, p95 313 ms, max 805 ms\n\nArgument quality vs. upvotes (Spearman, per portal):\n  medium_A     rho -0.07   mean quality 2.37   n=703\n  medium_B     rho -0.12   mean quality 2.65   n=1323\n  medium_C     rho -0.09   mean quality 2.59   n=1047\n\nStance intensity (0 = against, 4 = in favour) by stance.py label:\n  against    mean 0.67   n=1340\n  neutral    mean 1.91   n=24\n  in_favour  mean 3.56   n=774\n  unsure     mean 2.29   n=935\n  agreement Choice vs. Score: 98% of in_favour\u002Fagainst comments are on the matching side of 2\n",[71,1004,1002],{"__ignoreMap":69},[14,1006,1007],{},"Again fast and cheap: 3073 comments with two questions for 10 US cents.",[14,1009,1010],{},"An excerpt from the result:",[386,1012,388,1013,388,1019],{},[390,1014],{"src":1015,"alt":1016,"width":1017,"height":1018,"loading":396},"\u002Fimages\u002Fwriting\u002Fjev-first-steps\u002Fscore-result.png","A result table with the columns stance, quality_score, quality_confidence, quality_level, intensity_score, intensity_confidence, my_quality, input_tokens, output_tokens and duration_ms",1138,1062,[398,1020,1021],{},"One row per comment: the stance carried over from step 2, next to both Score answers with their confidence.",[14,1023,1024],{},"The columns in detail:",[695,1026,1027,1038],{},[698,1028,1029],{},[701,1030,1031,1033,1036],{},[704,1032,706],{},[704,1034,1035],{},"Source",[704,1037,709],{},[711,1039,1040,1061,1076,1089,1101,1115],{},[701,1041,1042,1046,1049],{},[716,1043,1044],{"data-label":706},[71,1045,766],{},[716,1047,1048],{"data-label":1035},"Step 2 (Choice), carried over unchanged",[716,1050,1051,1052,416,1054,416,1056,1058,1059,424],{"data-label":709},"The final result of the stance question: ",[71,1053,415],{},[71,1055,419],{},[71,1057,423],{}," or ",[71,1060,194],{},[701,1062,1063,1068,1073],{},[716,1064,1065],{"data-label":706},[71,1066,1067],{},"quality_score",[716,1069,1070,1071],{"data-label":1035},"Score question ",[71,1072,832],{},[716,1074,1075],{"data-label":709},"How well-reasoned the comment is, on a scale from 0 to 4. It is the average of the levels, weighted by their probabilities.",[701,1077,1078,1083,1086],{},[716,1079,1080],{"data-label":706},[71,1081,1082],{},"quality_confidence",[716,1084,1085],{"data-label":1035},"same question",[716,1087,1088],{"data-label":709},"How certain Jev is, from 0 to 1. Close to 1 means almost all probability is on one level. Close to 0 means it is spread across several levels.",[701,1090,1091,1096,1098],{},[716,1092,1093],{"data-label":706},[71,1094,1095],{},"quality_level",[716,1097,1085],{"data-label":1035},[716,1099,1100],{"data-label":709},"The single level with the highest probability, 0 to 4. This is a bucket, not an average.",[701,1102,1103,1108,1112],{},[716,1104,1105],{"data-label":706},[71,1106,1107],{},"intensity_score",[716,1109,1070,1110],{"data-label":1035},[71,1111,838],{},[716,1113,1114],{"data-label":709},"The stance on a spectrum: 0 = clearly against, 2 = no position, 4 = clearly in favour. Also a weighted average.",[701,1116,1117,1122,1124],{},[716,1118,1119],{"data-label":706},[71,1120,1121],{},"intensity_confidence",[716,1123,1085],{"data-label":1035},[716,1125,1126],{"data-label":709},"How certain Jev is about the intensity.",[47,1128,1130],{"id":1129},"conclusion","Conclusion",[14,1132,1133],{},"My first impression: Jev is very easy to use. One question, a few criteria, and you get a clean,\ntyped answer back. That makes getting started easy. All the runs in this article together cost\naround one US dollar.",[14,1135,1136],{},"What I'm taking away:",[445,1138,1139,1145,1151,1157],{},[28,1140,1141,1144],{},[17,1142,1143],{},"Filter first, then ask."," Use Noul to sort out the irrelevant comments and only keep working\nwith the rest. This saves costs and makes the further results cleaner.",[28,1146,1147,1150],{},[17,1148,1149],{},"The decision lives in your own code."," Jev provides probabilities and confidence; you set the\nthresholds yourself. That's probably where the real work lies once it comes to a production use\ncase: finding the right threshold.",[28,1152,1153,1156],{},[17,1154,1155],{},"Not just an answer, but how certain it is."," With a classic LLM call and a predefined JSON\nschema you do get a label, but no reliable indication of how certain it is. You can tell it to\ninclude a confidence, but I don't know how good that is. With Jev you can react to exactly that:\nprocess automatically when the answer is clear, and otherwise have a human take a look.",[28,1158,1159,1162],{},[17,1160,1161],{},"Intelligence per Dollar is increasing."," I haven’t measured it precisely, but classifying the same amount of data using a standard AI API call would cost about 100 times as much.",[14,1164,1165],{},"For developers this is a big step, and I'm curious to see what other use cases will come up.",[14,1167,1168,1169],{},"If you'd like to see the code I used for this: ",[54,1170,1173],{"href":1171,"rel":1172},"https:\u002F\u002Fgithub.com\u002Fspirit1616\u002Fjev-playground",[58],"Jev-playground",[1175,1176,1177],"style",{},"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":69,"searchDepth":89,"depth":89,"links":1179},[1180,1181,1182,1183],{"id":49,"depth":83,"text":50},{"id":403,"depth":83,"text":404},{"id":816,"depth":83,"text":817},{"id":1129,"depth":83,"text":1130},"2026-09-26","Categorize and validate the quality of 8640 comments for just a dollar. That is high intelligence per dollar.",false,"md",null,{},"\u002Fwriting\u002Fjev-first-steps","BUILDING WITH AI",{"title":6,"description":1185},"writing\u002Fjev-first-steps","cJTnlFyx9_xGjdC3Ql_EZivXamDHhOHPmsBUzCWqWPo",{"id":1196,"title":1197,"body":1198,"date":1351,"description":1352,"draft":1186,"extension":1187,"image":1188,"meta":1353,"navigation":293,"path":1354,"pillar":1191,"readingTime":95,"seo":1355,"stem":1356,"__hash__":1357},"writing\u002Fwriting\u002Fthe-tours-matchmaker.md","The Matchmaker: AI route generation in a closed loop",{"type":8,"value":1199,"toc":1342},[1200,1203,1216,1220,1223,1227,1230,1233,1236,1248,1252,1255,1258,1261,1264,1274,1278,1281,1284,1294,1298,1301,1304,1308,1311,1322,1326],[14,1201,1202],{},"LLMs are easy to use for inspiration. Accuracy is where it still falls apart. Take a concrete\nrequest: a rider wants a motorcycle tour heading south, roughly three hours long. That is not\nsomething an LLM gets right on its own. Which is why the Matchmaker in The Tours is more than a\nprompt.",[386,1204,388,1205,388,1213],{},[390,1206],{"src":1207,"alt":1208,"width":1209,"height":1210,"loading":396,"className":1211},"\u002Fimages\u002Fwriting\u002Fthe-tours-matchmaker\u002Fmatchmaker-inputs.png","The Matchmaker input screen: a distance slider at 150 km, a curviness slider set to twisty, a difficulty stepper at 3, and a compass dial with north selected",320,693,[1212],"img--framed",[398,1214,1215],{},"What the rider gives the Matchmaker: distance, curviness, difficulty, direction.",[47,1217,1219],{"id":1218},"the-idea","The idea",[14,1221,1222],{},"The AI handles inspiration and selection. But all of it runs inside a closed loop that checks\nwhether the result matches the brief, across several iterations if that is what it takes.",[47,1224,1226],{"id":1225},"how-it-runs","How it runs",[14,1228,1229],{},"Passes and POIs that are interesting to motorcyclists sit in the database. They get pre-filtered\nfirst — they have to fall within a certain radius of the starting point, for instance, or suit the\nbike the rider picked for this tour.",[14,1231,1232],{},"The AI works from that pre-filtered pool and assembles a tour out of it. It picks any number of\npoints, and it puts them in an order that makes sense, so you are not criss-crossing the same\nregion twice.",[14,1234,1235],{},"Every point carries coordinates in the database. Those coordinates go to the GraphHopper Directions\nAPI, which computes the full tour from them — including the real time and distance, rather than\nsomething the AI made up.",[386,1237,388,1238,388,1245],{},[390,1239],{"src":1240,"alt":1241,"width":1242,"height":1243,"loading":396,"className":1244},"\u002Fimages\u002Fwriting\u002Fthe-tours-matchmaker\u002Floop-diagram.png","Flow diagram: user brief and pre-filtered pool feed into AI point selection, then GraphHopper measurement, then a check that either returns the tour to the rider or sends feedback back to the AI, with a hard break after three attempts",700,618,[1212],[398,1246,1247],{},"The loop: the AI selects, GraphHopper measures, the check decides.",[47,1249,1251],{"id":1250},"the-check","The check",[14,1253,1254],{},"Before a tour reaches the rider, it gets checked. Is the rough direction right? And does it land\nwithin a tolerance of 10% on time or distance?",[14,1256,1257],{},"If it does not, what failed gets recorded. The whole tour goes back to the AI together with that\nfeedback, so it can correct its selection of points.",[14,1259,1260],{},"The principle underneath: the AI supplies the creativity, a hard measurement validates the result,\nand the AI has to conform to that, not the other way around. That is what keeps hallucinations in\ncheck.",[14,1262,1263],{},"Depending on the region, generating a tour exactly to the brief can be difficult — a mountainous\narea with more or less a single valley road will produce unsuitable results quickly. So there is a\nhard break for now: after three attempts the loop stops and returns the last tour.",[386,1265,388,1266,388,1271],{},[390,1267],{"src":1268,"alt":1269,"width":1209,"height":1210,"loading":396,"className":1270},"\u002Fimages\u002Fwriting\u002Fthe-tours-matchmaker\u002Fgenerated-tour.png","A generated tour in the app: a loop drawn over the map of the Appenzell hills, titled Appenzeller Hügelland Rundtour, with a 65% match rating and buttons to regenerate or start the tour",[1212],[398,1272,1273],{},"A finished generated tour, including its match rating.",[47,1275,1277],{"id":1276},"why-use-ai-at-all","Why use AI at all",[14,1279,1280],{},"The main reason: the selection of points comes out better and more interesting with AI than it does\npurely algorithmically. The order of the points makes more sense, and what you get is a noticeably\nmore interesting tour that flows.",[14,1282,1283],{},"The AI does make generation measurably slower, though. A tour can take up to 30 seconds, depending\non how many iterations it needs to satisfy the brief. I cover that in the frontend with a nice\nanimation.",[386,1285,388,1286,388,1291],{},[390,1287],{"src":1288,"alt":1289,"width":1209,"height":1290,"loading":396},"\u002Fimages\u002Fwriting\u002Fthe-tours-matchmaker\u002Floading-animation.png","The generation screen: the app logo above the line Deine Tour wird generiert, with a checklist stepping through traffic, bike capabilities, weather and preferences",696,[398,1292,1293],{},"Something is always happening, so the wait feels shorter than it is.",[47,1295,1297],{"id":1296},"two-weak-points","Two weak points",[14,1299,1300],{},"First, the Matchmaker depends heavily on the passes and POIs in the database. The data is okay, but\nit is not extensive. For it to work well anywhere in the world, it has to become less dependent on\nthat data.",[14,1302,1303],{},"Second, the tours get a little restless when the loop has to iterate several times. Instead of a\nclean round trip you end up riding the given points in a jagged line. There is definitely room to\nimprove that.",[47,1305,1307],{"id":1306},"what-comes-next","What comes next",[14,1309,1310],{},"Next up is being able to adjust a generated tour through an interface — either with predefined\noptions or in free text. That will make the tours better again for the person actually riding them.",[386,1312,388,1313,388,1319],{},[390,1314],{"src":1315,"alt":1316,"width":1209,"height":1317,"loading":396,"className":1318},"\u002Fimages\u002Fwriting\u002Fthe-tours-matchmaker\u002Ftour-feedback.png","The Tour anpassen sheet: chips like too short, too long, too twisty and too much motorway, a free-text field for keywords, a bike picker, and a regenerate button",1081,[1212],[398,1320,1321],{},"New ways to give feedback on a generated tour.",[47,1323,1325],{"id":1324},"tools","Tools",[445,1327,1328,1335],{},[28,1329,1330],{},[54,1331,1334],{"href":1332,"rel":1333},"https:\u002F\u002Fwww.graphhopper.com\u002Fproducts\u002Fdirections-api\u002F",[58],"GraphHopper Directions API",[28,1336,1337],{},[54,1338,1341],{"href":1339,"rel":1340},"https:\u002F\u002Fthetours.ch",[58],"The Tours",{"title":69,"searchDepth":89,"depth":89,"links":1343},[1344,1345,1346,1347,1348,1349,1350],{"id":1218,"depth":83,"text":1219},{"id":1225,"depth":83,"text":1226},{"id":1250,"depth":83,"text":1251},{"id":1276,"depth":83,"text":1277},{"id":1296,"depth":83,"text":1297},{"id":1306,"depth":83,"text":1307},{"id":1324,"depth":83,"text":1325},"2026-07-08","An LLM cannot tell you how long a motorcycle tour actually takes. So the Matchmaker lets the AI pick the points and a hard measurement decide whether the result is allowed through.",{},"\u002Fwriting\u002Fthe-tours-matchmaker",{"title":1197,"description":1352},"writing\u002Fthe-tours-matchmaker","52fnAJHGH_OJnTxt4FA6StffGZGwGlpzciUILaTDP_g",{"id":1359,"title":1360,"body":1361,"date":1698,"description":1699,"draft":1186,"extension":1187,"image":1188,"meta":1700,"navigation":293,"path":1701,"pillar":1191,"readingTime":101,"seo":1702,"stem":1703,"__hash__":1704},"writing\u002Fwriting\u002Fhow-i-rebuilt-the-tours-app.md","How I rebuilt The Tours app: stack and workflow",{"type":8,"value":1362,"toc":1683},[1363,1366,1370,1432,1435,1439,1442,1445,1456,1460,1463,1474,1478,1481,1501,1504,1507,1513,1516,1520,1523,1534,1539,1542,1546,1549,1553,1560,1564,1567,1570,1574,1577,1627,1630,1632,1635,1638,1641,1643],[14,1364,1365],{},"The Tours solves one problem: finding good motorcycle roads, wherever you happen to be. Routes\nget AI-generated context where context helps. Over the last eight months I rebuilt the whole\nthing — the app and the backend — and the point of this piece is the workflow, not the app.",[47,1367,1369],{"id":1368},"the-stack","The stack",[695,1371,1372,1382],{},[698,1373,1374],{},[701,1375,1376,1379],{},[704,1377,1378],{},"Layer",[704,1380,1381],{},"Tool",[711,1383,1384,1392,1400,1408,1416,1424],{},[701,1385,1386,1389],{},[716,1387,1388],{"data-label":1378},"Development",[716,1390,1391],{"data-label":1381},"Cursor",[701,1393,1394,1397],{},[716,1395,1396],{"data-label":1378},"Backend",[716,1398,1399],{"data-label":1381},"Strapi",[701,1401,1402,1405],{},[716,1403,1404],{"data-label":1378},"Mobile app",[716,1406,1407],{"data-label":1381},"React Native",[701,1409,1410,1413],{},[716,1411,1412],{"data-label":1378},"UI development",[716,1414,1415],{"data-label":1381},"Storybook",[701,1417,1418,1421],{},[716,1419,1420],{"data-label":1378},"Design",[716,1422,1423],{"data-label":1381},"Figma",[701,1425,1426,1429],{},[716,1427,1428],{"data-label":1378},"AI support",[716,1430,1431],{"data-label":1381},"Claude \u002F GPT",[14,1433,1434],{},"The individual picks matter less than how they connect. What made this work is that every feature\ntravels the same documented path, from first idea to shipped screen. Six steps.",[47,1436,1438],{"id":1437},"step-1-design-and-design-tokens","Step 1: Design and design tokens",[14,1440,1441],{},"The Figma designs came before any app code, and the design tokens went into code at the same\ntime: colours, spacing, border radius, shadows, all as named values.",[14,1443,1444],{},"That pays off the moment Cursor works from a Figma screen in Plan mode. It reads the tokens\ndirectly instead of pulling hex values out of a design, so the implementation stays close to the\noriginal without me copying numbers by hand.",[386,1446,388,1447,388,1453],{},[390,1448],{"src":1449,"alt":1450,"width":1451,"height":1452,"loading":396},"\u002Fimages\u002Fwriting\u002Fhow-i-rebuilt-the-tours-app\u002Fdesign-tokens.png","The primary brand colour defined as a design token, with the 50 to 900 scale mapped onto baseColors.primary",524,504,[398,1454,1455],{},"The primary colour as a design token — the value Cursor reads instead of a hex code from the design.",[47,1457,1459],{"id":1458},"step-2-a-component-library-in-storybook","Step 2: A component library in Storybook",[14,1461,1462],{},"From the Figma screens I built isolated, documented component stories in Storybook, again in Plan\nmode. That library then became the reference for every feature after it — it is how Cursor knows\nwhich component already exists instead of inventing a fourth button.",[386,1464,388,1465,388,1471],{},[390,1466],{"src":1467,"alt":1468,"width":1209,"height":1469,"loading":396,"className":1470},"\u002Fimages\u002Fwriting\u002Fhow-i-rebuilt-the-tours-app\u002Fstorybook-component.png","Storybook running on a phone, showing the AtomAnimation story above the sidebar list of Atom components",697,[1212],[398,1472,1473],{},"The AtomAnimation story in Storybook, next to the rest of the Atom layer.",[47,1475,1477],{"id":1476},"step-3-generating-user-stories","Step 3: Generating user stories",[14,1479,1480],{},"I had the AI write the user stories from the Figma screens. Three things made the difference:",[445,1482,1483,1489,1495],{},[28,1484,1485,1488],{},[17,1486,1487],{},"Work through a whole epic at once."," Eight to ten stories in one pass, not one story at a time.",[28,1490,1491,1494],{},[17,1492,1493],{},"Give it an explicit structure."," The same template every time.",[28,1496,1497,1500],{},[17,1498,1499],{},"Make it ask first."," Have it ask clarifying questions before it generates anything, instead of\nguessing.",[14,1502,1503],{},"That last one is the big one. It typically came back with around 50 questions. Answering them up\nfront raised the quality of the stories noticeably.",[14,1505,1506],{},"The template:",[64,1508,1511],{"className":1509,"code":1510,"language":219,"meta":69},[217],"As a XXX I want XXX so that XXX\n\nAcceptance Criteria\nDescription \u002F Context\nDefinition of Done\nDependencies\nTechnical Notes\n",[71,1512,1510],{"__ignoreMap":69},[14,1514,1515],{},"I reviewed every generated story and added or cut acceptance criteria, but most of it was right on\nthe first pass. A lot of the requirements can be derived from the screen itself, so what I mostly\nadded were the requirements you cannot see in a design. Writing these stories by hand would have\ntaken noticeably longer.",[47,1517,1519],{"id":1518},"step-4-development-in-cursor","Step 4: Development in Cursor",[14,1521,1522],{},"Per feature, Cursor got three inputs: the user story, the relevant Figma screen, and — for\nfeatures that are pure logic with no new UI — the Storybook file instead of the screen, so it was\nclear which components to use.",[386,1524,388,1525,388,1531],{},[390,1526],{"src":1527,"alt":1528,"width":1529,"height":1530,"loading":396},"\u002Fimages\u002Fwriting\u002Fhow-i-rebuilt-the-tours-app\u002Fcursor-plan.png","A Cursor plan for the profile tour list: goal, bullet requirements, and a flow diagram from Profile through ProfileToursList to the tours endpoint",744,352,[398,1532,1533],{},"A Cursor plan before any code is written — goal, requirements, and the path through the components.",[1535,1536,1538],"h3",{"id":1537},"cursor-rules","Cursor rules",[14,1540,1541],{},"Rule files kept the guidelines consistent across features. That worked well for the app and much\nless well for the backend. Large rule files in the backend were actively counterproductive; the\nfix was keeping them deliberately minimal. My guess is that Strapi's own prescribed structure\npulled the model away from the actual task.",[1535,1543,1545],{"id":1544},"splitting-the-work","Splitting the work",[14,1547,1548],{},"Cursor did most of the work. I did the small styling corrections by hand. The code stayed visible\nthe whole time, which is what let me review and adjust as it went rather than after.",[47,1550,1552],{"id":1551},"step-5-backend-and-the-seam-to-the-frontend","Step 5: Backend and the seam to the frontend",[14,1554,1555,1556,1559],{},"Whenever Strapi got a new endpoint, a ",[71,1557,1558],{},".md"," file documenting it was written at the same time. That\ndocumentation then went into the next plan alongside the user story and the Figma screen, so the\nshape of the endpoint was clear from the first line of frontend code instead of being discovered\nhalfway through.",[47,1561,1563],{"id":1562},"step-6-testing","Step 6: Testing",[14,1565,1566],{},"Honestly: still mostly manual, and I want to automate more of it.",[14,1568,1569],{},"What exists today: visual regression tests through Storybook and Maestro, unit tests in the\nbackend with Jest, and a manual pass over every screen before each release.",[47,1571,1573],{"id":1572},"what-changed","What changed",[14,1575,1576],{},"Eight months, 583 development hours for the build — design hours are separate.",[695,1578,1579,1592],{},[698,1580,1581],{},[701,1582,1583,1586,1590],{},[704,1584,1585],{},"Metric",[704,1587,1589],{"align":1588},"right","Frontend",[704,1591,1396],{"align":1588},[711,1593,1594,1605,1616],{},[701,1595,1596,1599,1602],{},[716,1597,1598],{"data-label":1585},"Commits",[716,1600,1601],{"align":1588,"data-label":1589},"611",[716,1603,1604],{"align":1588,"data-label":1396},"139",[701,1606,1607,1610,1613],{},[716,1608,1609],{"data-label":1585},"Lines of code",[716,1611,1612],{"align":1588,"data-label":1589},"~378,000",[716,1614,1615],{"align":1588,"data-label":1396},"~221,900",[701,1617,1618,1621,1624],{},[716,1619,1620],{"data-label":1585},"Merge requests (est.)",[716,1622,1623],{"align":1588,"data-label":1589},"~90",[716,1625,1626],{"align":1588,"data-label":1396},"~14",[14,1628,1629],{},"For the same scope, I would put this at roughly three to four times faster. But the time moved\nrather than disappeared: far more of it went into defining features and testing them, because\nproducing the code itself takes seconds to minutes. What took longest was defining the base\nstructure and the library cleanly, plus the genuinely interactive pieces like the mapping.",[47,1631,1130],{"id":1129},[14,1633,1634],{},"I would do it this way again. You get to something usable fast.",[14,1636,1637],{},"The one habit worth stealing: when the AI goes off in the wrong direction, close the chat, stash\nthe changes, and start over with what you just learned. That beats trying to argue a stuck session\nback on track.",[14,1639,1640],{},"And the thing that actually decided the outcome was not the AI. It was the structure around it —\ndesign tokens, a clean component library, structured user stories, and a foundation of rules.",[47,1642,1325],{"id":1324},[445,1644,1645,1651,1658,1664,1670,1676],{},[28,1646,1647],{},[54,1648,1407],{"href":1649,"rel":1650},"https:\u002F\u002Freactnative.dev",[58],[28,1652,1653],{},[54,1654,1657],{"href":1655,"rel":1656},"https:\u002F\u002Fgithub.com\u002Fstorybookjs\u002Freact-native",[58],"React Native Storybook",[28,1659,1660],{},[54,1661,1399],{"href":1662,"rel":1663},"https:\u002F\u002Fstrapi.io",[58],[28,1665,1666],{},[54,1667,1391],{"href":1668,"rel":1669},"https:\u002F\u002Fcursor.com",[58],[28,1671,1672],{},[54,1673,1423],{"href":1674,"rel":1675},"https:\u002F\u002Ffigma.com",[58],[28,1677,1678],{},[54,1679,1682],{"href":1680,"rel":1681},"https:\u002F\u002Fclaude.ai",[58],"Claude",{"title":69,"searchDepth":89,"depth":89,"links":1684},[1685,1686,1687,1688,1689,1693,1694,1695,1696,1697],{"id":1368,"depth":83,"text":1369},{"id":1437,"depth":83,"text":1438},{"id":1458,"depth":83,"text":1459},{"id":1476,"depth":83,"text":1477},{"id":1518,"depth":83,"text":1519,"children":1690},[1691,1692],{"id":1537,"depth":89,"text":1538},{"id":1544,"depth":89,"text":1545},{"id":1551,"depth":83,"text":1552},{"id":1562,"depth":83,"text":1563},{"id":1572,"depth":83,"text":1573},{"id":1129,"depth":83,"text":1130},{"id":1324,"depth":83,"text":1325},"2026-06-20","I rebuilt The Tours from scratch in eight months and 583 hours — app and backend. Figma, Storybook, Cursor and structured user stories, including the parts that did not work.",{},"\u002Fwriting\u002Fhow-i-rebuilt-the-tours-app",{"title":1360,"description":1699},"writing\u002Fhow-i-rebuilt-the-tours-app","FEDOpemEtcJcxF3hMiCDZqLJWWugSvS6WigKSoNexyE",1792666225712]