The first friendly-pilot live run produced zero summaries: koala/phi4-mini hit three silent failure modes — 8k context overflow on long transcripts (HTTP 400), intermittent malformed JSON (highlights as a bare string), and no fallback wired at all (summarizer.New(primary, nil)). Keep phi4-mini as the fast primary and add resilience around it: - Ordered endpoint chain (summarizer.NewChain): phi4-mini → koala/phi4-14b (local) → berget/mistral-small (worst-case external). All reached through the one LiteLLM gateway by alias. - A parse failure now advances the chain like a transport error — the old Primary→Fallback shape returned the parse error without trying anyone else. - Tolerant parse: highlights/takeaways coerce string→[]string, absorbing the common small-model quirk without spending a fallback round-trip. - Transcript truncation (TAPIR_MAX_TRANSCRIPT_CHARS=18000) prevents the overflow rather than recovering from it; validated to fit phi4-mini's 8k window. - Bounded completion budget (TAPIR_SUMMARY_MAX_TOKENS=1500) — the old 8192 budget itself contributed to the overflow. Local-first guarantee preserved by ordering: external endpoint is tried only after every local one fails. TAPIR_CLOUD_FALLBACK_MODEL="" disables it entirely for client/NDA deployments. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
222 lines
8.8 KiB
Go
222 lines
8.8 KiB
Go
package acceptance
|
|
|
|
// This is the name-coverage gate for the BDD spec (see docs/use-cases/*.feature).
|
|
// There is no godog runner — the .feature files are design records, and the real
|
|
// behaviour is covered by the hand-written Go tests across the module. This test
|
|
// keeps the two from drifting in the cheapest honest way: every non-@pending
|
|
// Scenario must have an entry in scenarioCoverage pointing at a Go test that
|
|
// actually exists. It does NOT prove the test exercises the scenario (only godog
|
|
// could); it catches the common drift — "added a scenario, forgot the test", a
|
|
// renamed/deleted covering test, or a scenario removed without cleaning the map.
|
|
//
|
|
// When you add a Scenario: either map it here to its covering test, or tag it
|
|
// @pending in the .feature with a one-line reason for why it has no test yet.
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// scenarioCoverage maps each non-@pending Scenario name to the Go test that
|
|
// covers it. Keep it in sync with docs/use-cases/*.feature — the test below
|
|
// fails if a scenario is unmapped, a mapped test is missing, or an entry no
|
|
// longer matches a real non-pending scenario.
|
|
var scenarioCoverage = map[string]string{
|
|
// ai_routing.feature
|
|
"Local AI produces the summary": "TestSummarize_LocalSucceeds",
|
|
"Local AI fails and the user has a BYO provider configured": "TestSummarize_FallsBackToBYO",
|
|
"Local AI fails and the user has no BYO provider": "TestSummarize_LocalFailsNoBYO_NoExternalSend",
|
|
"A user without BYO never has content sent externally": "TestSummarize_NoBYO_ContentOnlyLocal",
|
|
"A model returns unparseable output and the next endpoint succeeds": "TestSummarize_FallsBackOnMalformedOutput",
|
|
|
|
// landing_page.feature
|
|
"An unauthenticated visit to the root is sent to the welcome page": "TestUnauthenticatedRootRedirectsToWelcome",
|
|
"The welcome page invites an unauthenticated visitor to start": "TestWelcomeLoggedOut",
|
|
"An authenticated user on the welcome page sees their way in and out": "TestWelcomeLoggedIn",
|
|
|
|
// connect_account.feature
|
|
"Connect a YouTube account": "TestCallbackExchangesAndRecordsConnection",
|
|
"Connecting an account discovers videos immediately": "TestCallbackTriggersDiscovery",
|
|
"Connecting summarizes my newest videos right away": "TestNewestUnsummarizedVideoIDs",
|
|
"Tokens are never stored in the clear": "TestCallbackExchangesAndRecordsConnection",
|
|
|
|
// paste_url.feature
|
|
"Paste a valid YouTube URL": "TestPasteValidURLAddsAndRequests",
|
|
"Pasting an invalid link is rejected": "TestPasteInvalidURLRejected",
|
|
"Pasting a video that cannot be found is honest": "TestPasteVideoNotFound",
|
|
"Pasting the same video twice does not duplicate it": "TestPasteDedupNoDuplicate",
|
|
"Revoking a connection stops watching but keeps history": "TestDisconnectRemovesTokenAndConnectionKeepsAccount",
|
|
|
|
// summarize_mode.feature
|
|
"Auto mode summarizes recent new videos automatically": "TestRunOnce_AutoMode_SkipsOldVideos",
|
|
"Auto mode lists older videos without summarizing them": "TestRunOnce_AutoMode_SkipsOldVideos",
|
|
"Automatic is the default for a new user": "TestRegisteredUserDefaultsAutoSummarizeOn",
|
|
"Manual mode leaves new videos unsummarized": "TestRunOnce_ManualMode_SkipsUnrequested",
|
|
"Requesting a summary in manual mode queues it for the next run": "TestRunOnce_ManualMode_ProcessesRequested",
|
|
|
|
// registration.feature
|
|
"A new Dex subject is routed to registration": "TestUnregisteredSubjectRedirectedToRegister",
|
|
"Registering creates the account and its identity mapping": "TestRegisterCreatesExactlyOneUserAndIdentity",
|
|
"A returning subject passes straight through": "TestRegisteredSubjectPassesThrough",
|
|
"Deleting an account removes only my data and leaves other users untouched": "TestDeleteAccountWipesDataAndSecretsAndLogsOut",
|
|
|
|
// summarize_new_video.feature
|
|
"A subscribed channel posts a video that has captions": "TestSubscribedVideoWithCaptionsIsSummarizedAndDelivered",
|
|
"A subscribed channel posts a video with no usable transcript": "TestVideoWithNoTranscriptIsSkipped",
|
|
"A channel I am not subscribed to posts a video": "TestUnsubscribedChannelVideoIsNotProcessed",
|
|
"The same video is not summarized twice": "TestAlreadySummarizedVideoIsNotReprocessed",
|
|
"Re-analyzing a stored video does not re-fetch its transcript": "TestProcessNewVideo_SecondSummarizeDoesNotRefetch",
|
|
}
|
|
|
|
var (
|
|
scenarioRe = regexp.MustCompile(`^\s*Scenario(?: Outline)?:\s*(.+?)\s*$`)
|
|
testFuncRe = regexp.MustCompile(`^func (Test\w+)\(`)
|
|
)
|
|
|
|
// scenario is one parsed Gherkin scenario and whether it is @pending.
|
|
type scenario struct {
|
|
name string
|
|
pending bool
|
|
}
|
|
|
|
func TestScenarioCoverage(t *testing.T) {
|
|
root := moduleRoot(t)
|
|
|
|
scenarios := parseScenarios(t, filepath.Join(root, "docs", "use-cases"))
|
|
if len(scenarios) == 0 {
|
|
t.Fatal("no scenarios parsed from docs/use-cases — wrong path?")
|
|
}
|
|
tests := allTestFuncNames(t, root)
|
|
|
|
// Index scenario names for the reverse (stale-entry) check.
|
|
active := map[string]bool{} // non-pending scenario names
|
|
var pending []string
|
|
for _, s := range scenarios {
|
|
if s.pending {
|
|
pending = append(pending, s.name)
|
|
continue
|
|
}
|
|
active[s.name] = true
|
|
|
|
// 1. Every non-pending scenario must be mapped.
|
|
fn, ok := scenarioCoverage[s.name]
|
|
if !ok {
|
|
t.Errorf("scenario %q has no coverage entry — map it in scenarioCoverage to a covering test, or tag it @pending in the .feature", s.name)
|
|
continue
|
|
}
|
|
// 2. The mapped test must actually exist.
|
|
if !tests[fn] {
|
|
t.Errorf("scenario %q maps to %q, which is not a Test function anywhere in the module", s.name, fn)
|
|
}
|
|
}
|
|
|
|
// 3. No stale entries: every map key must be a real, non-pending scenario.
|
|
for name := range scenarioCoverage {
|
|
if !active[name] {
|
|
t.Errorf("scenarioCoverage has entry %q, which is not a current non-pending scenario (renamed, removed, or now @pending?)", name)
|
|
}
|
|
}
|
|
|
|
if len(pending) > 0 {
|
|
t.Logf("%d @pending scenario(s) without a test (tracked, not required): %s",
|
|
len(pending), strings.Join(pending, "; "))
|
|
}
|
|
}
|
|
|
|
// parseScenarios reads every *.feature under dir and returns its scenarios with
|
|
// their @pending status. A scenario is @pending when a `@pending` tag line
|
|
// precedes it (tags survive intervening comment lines, the layout these files
|
|
// use); the flag is consumed at the Scenario line and reset afterward.
|
|
func parseScenarios(t *testing.T, dir string) []scenario {
|
|
t.Helper()
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
t.Fatalf("read use-cases dir: %v", err)
|
|
}
|
|
var out []scenario
|
|
for _, e := range entries {
|
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".feature") {
|
|
continue
|
|
}
|
|
b, err := os.ReadFile(filepath.Join(dir, e.Name()))
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", e.Name(), err)
|
|
}
|
|
pending := false
|
|
for _, line := range strings.Split(string(b), "\n") {
|
|
trimmed := strings.TrimSpace(line)
|
|
if strings.HasPrefix(trimmed, "@") {
|
|
if strings.Contains(trimmed, "@pending") {
|
|
pending = true
|
|
}
|
|
continue
|
|
}
|
|
if m := scenarioRe.FindStringSubmatch(line); m != nil {
|
|
out = append(out, scenario{name: m[1], pending: pending})
|
|
pending = false
|
|
}
|
|
// comment (#) and step lines leave a set @pending intact until the
|
|
// scenario consumes it; a blank line between scenarios is harmless.
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// allTestFuncNames walks the module for `func TestXxx(` declarations, excluding
|
|
// this file (whose regex literal would otherwise look like a definition).
|
|
func allTestFuncNames(t *testing.T, root string) map[string]bool {
|
|
t.Helper()
|
|
self := "scenario_coverage_test.go"
|
|
names := map[string]bool{}
|
|
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if d.IsDir() {
|
|
if d.Name() == ".git" {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
if !strings.HasSuffix(path, "_test.go") || filepath.Base(path) == self {
|
|
return nil
|
|
}
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, line := range strings.Split(string(b), "\n") {
|
|
if m := testFuncRe.FindStringSubmatch(line); m != nil {
|
|
names[m[1]] = true
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("walk module: %v", err)
|
|
}
|
|
return names
|
|
}
|
|
|
|
// moduleRoot walks up from the working directory to the dir containing go.mod.
|
|
func moduleRoot(t *testing.T) string {
|
|
t.Helper()
|
|
dir, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatalf("getwd: %v", err)
|
|
}
|
|
for {
|
|
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
|
|
return dir
|
|
}
|
|
parent := filepath.Dir(dir)
|
|
if parent == dir {
|
|
t.Fatal("go.mod not found walking up from cwd")
|
|
}
|
|
dir = parent
|
|
}
|
|
}
|