@polymorphism-tech/morph-spec 8.33.0 → 8.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +22 -2
- package/README.md +2 -2
- package/bin/morph-spec.js +43 -5
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +5 -4
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +2 -2
- package/framework/MORPH.md +20 -22
- package/framework/agents.json +198 -44
- package/framework/ai-pin.json +22 -0
- package/framework/commands/morph-media.md +45 -0
- package/framework/docs/CLI.md +238 -10
- package/framework/evals/proof-rubrics/README.md +76 -0
- package/framework/evals/proof-rubrics/eval-de-modelo.md +64 -0
- package/framework/evals/proof-rubrics/fiacao.md +54 -0
- package/framework/evals/proof-rubrics/guard-cego.md +52 -0
- package/framework/evals/proof-rubrics/media-claims.md +109 -0
- package/framework/evals/proof-rubrics/mutation-proof.md +77 -0
- package/framework/evals/proof-rubrics/observabilidade-genai.md +119 -0
- package/framework/evals/proof-rubrics/verde-nao-e-coberto.md +48 -0
- package/framework/evals/rubrics/architecture.md +30 -0
- package/framework/evals/rubrics/code-quality.md +26 -0
- package/framework/evals/rubrics/test-coverage.md +20 -0
- package/framework/hooks/README.md +11 -1
- package/framework/hooks/claude-code/core/block-destructive-worktree-removal.js +5 -8
- package/framework/hooks/claude-code/core/block-raw-worktree-add.js +79 -0
- package/framework/hooks/claude-code/core/chain.js +27 -7
- package/framework/hooks/claude-code/core/protect-spec-files.js +41 -4
- package/framework/hooks/claude-code/core/worktree-context.js +83 -14
- package/framework/hooks/claude-code/core/worktree-create.js +206 -0
- package/framework/hooks/claude-code/core/worktree-env-inject.js +87 -0
- package/framework/hooks/claude-code/core/worktree-remove.js +81 -0
- package/framework/hooks/shared/command-tokens.js +23 -0
- package/framework/hooks/shared/e2e-check.js +0 -0
- package/framework/hooks/shared/git-paths.js +25 -0
- package/framework/hooks/shared/hook-response.js +17 -0
- package/framework/hooks/shared/morph-cli.js +134 -0
- package/framework/rules/ai-agents-standards.md +14 -2
- package/framework/rules/infrastructure-standards.md +3 -1
- package/framework/schemas/ai-pin.schema.json +97 -0
- package/framework/schemas/config.schema.json +107 -0
- package/framework/schemas/feature.schema.json +23 -4
- package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
- package/framework/skills/core/morph-apply/SKILL.md +28 -7
- package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +2 -2
- package/framework/skills/core/morph-implement/SKILL.md +20 -2
- package/framework/skills/core/morph-media/SKILL.md +215 -0
- package/framework/skills/core/morph-plan/SKILL.md +38 -4
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +166 -9
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/skills/core/morph-uiux/SKILL.md +1 -1
- package/framework/standards/PROVENANCE.json +81 -3
- package/framework/standards/STANDARDS.json +1504 -830
- package/framework/standards/ai-agents/agent-archetypes.md +160 -0
- package/framework/standards/ai-agents/agent-session.md +256 -160
- package/framework/standards/ai-agents/agent-spec.md +196 -0
- package/framework/standards/ai-agents/batching.md +217 -193
- package/framework/standards/ai-agents/context-providers.md +261 -188
- package/framework/standards/ai-agents/conversational-agent-with-phases.md +239 -0
- package/framework/standards/ai-agents/cost-and-budget.md +256 -0
- package/framework/standards/ai-agents/durable-workflows-hitl.md +98 -5
- package/framework/standards/ai-agents/evals-with-cache.md +289 -0
- package/framework/standards/ai-agents/guardrails.md +222 -0
- package/framework/standards/ai-agents/llm-runtime-defaults.md +226 -0
- package/framework/standards/ai-agents/mcp-server.md +351 -250
- package/framework/standards/ai-agents/mcp-tools.md +250 -197
- package/framework/standards/ai-agents/media-pipeline.md +352 -0
- package/framework/standards/ai-agents/media-video.md +267 -0
- package/framework/standards/ai-agents/middleware-patterns.md +241 -207
- package/framework/standards/ai-agents/modalities-image-gen.md +422 -173
- package/framework/standards/ai-agents/modalities-speech-to-text.md +177 -171
- package/framework/standards/ai-agents/modalities-text-to-speech.md +189 -181
- package/framework/standards/ai-agents/modalities-vision.md +205 -201
- package/framework/standards/ai-agents/multi-agent-patterns.md +211 -155
- package/framework/standards/ai-agents/observability-patterns.md +227 -17
- package/framework/standards/ai-agents/production.md +260 -231
- package/framework/standards/ai-agents/prompt-sources.md +389 -0
- package/framework/standards/ai-agents/providers/model-registry.md +293 -158
- package/framework/standards/ai-agents/rag-custom-pgvector.md +69 -4
- package/framework/standards/ai-agents/service-tier-flex.md +143 -135
- package/framework/standards/ai-agents/setup.md +166 -50
- package/framework/standards/ai-agents/structured-output.md +280 -249
- package/framework/standards/ai-agents/sweet-spot.md +129 -109
- package/framework/standards/ai-agents/testing-ai.md +236 -0
- package/framework/standards/ai-agents/vector-data-extensions.md +50 -20
- package/framework/standards/ai-agents/workflows.md +27 -7
- package/framework/standards/frontend/design-system/ai-image-generation.md +12 -0
- package/framework/standards/frontend/design-system/ai-video-generation.md +13 -0
- package/framework/standards/infrastructure/docker/local-compose-isolation.md +90 -0
- package/framework/templates/REGISTRY.json +14 -1
- package/framework/templates/code/dotnet/ai-agents/PromptRepository.cs.template +95 -95
- package/framework/templates/code/sql/ai-agents/agent-prompts-migration.sql.template +58 -58
- package/framework/templates/dotnet/ai-kit/Directory.Build.props +25 -0
- package/framework/templates/dotnet/ai-kit/Directory.Packages.props +26 -0
- package/framework/templates/dotnet/ai-kit/Morph.AiKit.sln +56 -0
- package/framework/templates/dotnet/ai-kit/NuGet.config +12 -0
- package/framework/templates/dotnet/ai-kit/README.md +128 -0
- package/framework/templates/dotnet/ai-kit/global.json +9 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentApi.cs +65 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentFactory.cs +289 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentFactoryOptions.cs +91 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentSpec.cs +91 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentSpecOptions.cs +181 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/AiKitAssemblyMarker.cs +14 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Conversation/ConversationStore.cs +174 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Conversation/IConversationStore.cs +54 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Conversation/InMemoryConversationStore.cs +75 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/CountingChatClient.cs +71 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalAssertion.cs +246 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalCacheMissException.cs +57 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalCacheOptions.cs +78 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalCacheStamp.cs +225 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalFixture.cs +463 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalMode.cs +32 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalQualityEvaluators.cs +49 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalScenario.cs +214 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalSummary.cs +168 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/OfflineChatClient.cs +142 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/ShortPathResponseCache.cs +349 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/IMediaGenerator.cs +42 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/IMediaProvider.cs +77 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaArtifact.cs +28 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaDisclosure.cs +107 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaGenerator.cs +253 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaIdempotencyKey.cs +103 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaJob.cs +259 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaJobId.cs +21 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaJobState.cs +47 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaPricing.cs +102 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaProvenance.cs +44 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaRequest.cs +192 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaResult.cs +94 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaReview.cs +99 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaSeams.cs +97 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaStore.cs +133 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MorphMedia.cs +93 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/Providers/GoogleImageProvider.cs +112 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/Providers/OpenAIImageProvider.cs +288 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/Providers/VeoVideoProvider.cs +158 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Middleware/IUsageCostSink.cs +29 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Middleware/UsageCostMiddleware.cs +208 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Middleware/UsageCostRecord.cs +95 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Morph.AiKit.csproj +38 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Observability/MorphOpenTelemetryExtensions.cs +119 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Observability/MorphTelemetryOptions.cs +82 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Providers/IProviderClientFactory.cs +33 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Providers/ModelAlias.cs +267 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Providers/ModelRegistry.cs +474 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Providers/model-registry.v2.json +87 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Rag/DeltaIngest.cs +169 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Rag/DeltaPlan.cs +49 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/AgentFactoryTests.cs +495 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/AgentSpecTests.cs +260 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/ConversationStoreTests.cs +405 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/DeltaIngestTests.cs +379 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalAssertionTests.cs +197 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalCacheMissTests.cs +139 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalCacheStampTests.cs +157 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalFailuresTests.cs +178 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalFixtureTests.cs +153 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalRefreshTests.cs +194 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalWorkspace.cs +86 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/KitEvals.cs +213 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/OfflineChatClientTests.cs +95 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeChatClient.cs +225 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeChatClientTests.cs +215 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeClock.cs +33 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeMediaProvider.cs +89 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeProviderClientFactory.cs +55 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeUsageCostSink.cs +60 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/InMemoryMediaStore.cs +131 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaAccessibilityTests.cs +133 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaAliasIsNotAnAgentTests.cs +127 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaDisclosureTests.cs +216 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaGeneratorTests.cs +264 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaIdempotencyKeyTests.cs +148 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaJobStateTests.cs +184 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaReviewTests.cs +89 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaStoreTests.cs +98 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/ModelRegistryTests.cs +451 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Morph.AiKit.Tests.csproj +76 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/SkeletonSmokeTests.cs +33 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/TelemetryTests.cs +261 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/UsageCostMiddlewareTests.cs +374 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-cache/.morph-eval-cache.json +12 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-cache/cache/orcamento.acme.a-resposta-estruturada/1/CF70A7CE27BBD66A/contents.data +13 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-cache/cache/orcamento.acme.a-resposta-estruturada/1/CF70A7CE27BBD66A/entry.json +7 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-cache/cache/triagem.acme.a-saudacao/1/19F631228C6E00BA/contents.data +13 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-cache/cache/triagem.acme.a-saudacao/1/19F631228C6E00BA/entry.json +7 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-cache/cache/triagem.acme.b-escalonamento/1/A24A7C9BDD8239D8/contents.data +13 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-cache/cache/triagem.acme.b-escalonamento/1/A24A7C9BDD8239D8/entry.json +7 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-cache/cache/triagem.globex.a-saudacao/1/E38CD37DF003EDB7/contents.data +13 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-cache/cache/triagem.globex.a-saudacao/1/E38CD37DF003EDB7/entry.json +7 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-datasets/orcamento/acme/a-resposta-estruturada.json +24 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-datasets/triagem/acme/a-saudacao.json +23 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-datasets/triagem/acme/b-escalonamento.json +24 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-datasets/triagem/globex/a-saudacao.json +23 -0
- package/framework/templates/infrastructure/docker/docker-compose.template.yml +24 -14
- package/package.json +3 -3
- package/src/commands/e2e/e2e.js +91 -40
- package/src/commands/project/doctor.js +143 -6
- package/src/commands/project/env.js +95 -0
- package/src/commands/project/update.js +469 -153
- package/src/commands/project/worktree.js +274 -53
- package/src/commands/state/advance.js +24 -2
- package/src/commands/state/approve.js +33 -1
- package/src/commands/state/create.js +72 -11
- package/src/commands/state/delete.js +8 -0
- package/src/commands/state/finish.js +360 -53
- package/src/commands/state/gate-check.js +190 -6
- package/src/commands/state/score.js +36 -1
- package/src/commands/validation/verify.js +134 -25
- package/src/core/state/state-manager.js +9 -1
- package/src/dashboard/app/pages/HookInspector.jsx +430 -418
- package/src/dashboard/scanner/hooks-scanner.js +5 -1
- package/src/lib/ai/ai-checks.js +182 -0
- package/src/lib/ai/ai-project-scan.js +347 -0
- package/src/lib/ai/code-audit.js +181 -0
- package/src/lib/ai/comment-stripper.js +184 -0
- package/src/lib/ai/pin-drift.js +138 -0
- package/src/lib/ai/registry-audit.js +285 -0
- package/src/lib/e2e/compose-env.js +55 -14
- package/src/lib/e2e/stack-teardown.js +115 -0
- package/src/lib/evaluator-report.js +269 -15
- package/src/lib/generators/architecture-scanner.js +2 -1
- package/src/lib/git/default-branch.js +92 -4
- package/src/lib/graph/graphifyignore.js +2 -1
- package/src/lib/installers/effects.js +193 -0
- package/src/lib/installers/templates-reconcile.js +3 -3
- package/src/lib/risk-detector.js +284 -0
- package/src/lib/standards/verified-against.js +276 -0
- package/src/lib/tasks/eval-node.js +485 -0
- package/src/lib/tasks/linux-runner.js +297 -0
- package/src/lib/tasks/test-nodes.js +486 -0
- package/src/lib/tasks/test-runner.js +431 -37
- package/src/lib/validators/verify-runner.js +590 -28
- package/src/lib/worktree/dirs.js +32 -0
- package/src/lib/worktree/docker-audit.js +287 -0
- package/src/lib/worktree/env-allocator.js +69 -11
- package/src/lib/worktree/worktree-audit.js +70 -3
- package/src/scripts/setup-infra.js +24 -11
- package/src/utils/agents-installer.js +18 -8
- package/src/utils/claude-md-injector.js +10 -5
- package/src/utils/file-copier.js +28 -16
- package/src/utils/hooks-installer.js +75 -29
- package/src/utils/skills-installer.js +194 -187
- package/src/utils/version-checker.js +68 -3
- package/framework/standards/ai-agents/prompts-in-db.md +0 -214
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
using System.Security.Cryptography;
|
|
2
|
+
using Microsoft.Extensions.AI.Evaluation.Quality;
|
|
3
|
+
using Morph.AiKit.Evals;
|
|
4
|
+
using Morph.AiKit.Tests.Fakes;
|
|
5
|
+
using Xunit;
|
|
6
|
+
|
|
7
|
+
namespace Morph.AiKit.Tests.Evals;
|
|
8
|
+
|
|
9
|
+
/// <summary>
|
|
10
|
+
/// Regravacao, e o invariante barato que protege a premissa de commitabilidade:
|
|
11
|
+
/// <b>uma leitura nao escreve um byte na arvore de cache</b>.
|
|
12
|
+
/// </summary>
|
|
13
|
+
public sealed class EvalRefreshTests
|
|
14
|
+
{
|
|
15
|
+
/// <summary>
|
|
16
|
+
/// Impressao digital da arvore: caminho relativo mais SHA-256 de cada arquivo.
|
|
17
|
+
/// </summary>
|
|
18
|
+
/// <remarks>
|
|
19
|
+
/// Comparar CONTEUDO, e nao data de modificacao: o que importa e se o proximo
|
|
20
|
+
/// <c>git status</c> mostra diff, e o git tambem olha conteudo. Um teste sobre mtime
|
|
21
|
+
/// falharia por gravacao identica e passaria por gravacao diferente no mesmo tick.
|
|
22
|
+
/// </remarks>
|
|
23
|
+
private static IReadOnlyList<string> Fingerprint(string root)
|
|
24
|
+
{
|
|
25
|
+
if (!Directory.Exists(root))
|
|
26
|
+
{
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return [.. Directory
|
|
31
|
+
.EnumerateFiles(root, "*", SearchOption.AllDirectories)
|
|
32
|
+
.OrderBy(p => p.Replace('\\', '/'), StringComparer.Ordinal)
|
|
33
|
+
.Select(p => $"{Path.GetRelativePath(root, p).Replace('\\', '/')} {Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(p)))}")];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private static void WriteThreeScenarios(EvalWorkspace ws, FakeChatClient fake)
|
|
37
|
+
{
|
|
38
|
+
foreach (var name in new[] { "a-primeiro", "b-segundo", "c-terceiro" })
|
|
39
|
+
{
|
|
40
|
+
ws.WriteScenario("triagem", "acme", name, $"pergunta {name}", """[ { "kind": "contains", "value": "resposta" } ]""");
|
|
41
|
+
fake.EnqueueText($"resposta {name}");
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
[Fact]
|
|
46
|
+
public async Task AReadWithAWarmCacheLeavesTheTreeBYTEForBYTEIdentical()
|
|
47
|
+
{
|
|
48
|
+
using var ws = new EvalWorkspace();
|
|
49
|
+
using var fake = new FakeChatClient { MetadataProviderName = "morph-fake", MetadataDefaultModelId = "modelo-exemplar" };
|
|
50
|
+
WriteThreeScenarios(ws, fake);
|
|
51
|
+
|
|
52
|
+
await using (var refresh = EvalFixture.Create(ws.Options(EvalMode.Refresh), fake))
|
|
53
|
+
{
|
|
54
|
+
await refresh.RunAllAsync(TestContext.Current.CancellationToken);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
var before = Fingerprint(ws.CacheRoot);
|
|
58
|
+
Assert.NotEmpty(before);
|
|
59
|
+
|
|
60
|
+
await using (var offline = EvalFixture.Create(ws.Options(EvalMode.Offline)))
|
|
61
|
+
{
|
|
62
|
+
var summary = await offline.RunAllAsync(TestContext.Current.CancellationToken);
|
|
63
|
+
Assert.Equal(3, summary.Passed);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Se algum dia a biblioteca passar a tocar disco no caminho de HIT, este canario morde
|
|
67
|
+
// no dia em que isso acontecer — e nao meses depois, quando alguem estranhar um diff
|
|
68
|
+
// que aparece sozinho em toda rodada.
|
|
69
|
+
Assert.Equal(before, Fingerprint(ws.CacheRoot));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
[Fact]
|
|
73
|
+
public async Task ExecutionResultsLandOUTSIDETheCommittedCacheTree()
|
|
74
|
+
{
|
|
75
|
+
using var ws = new EvalWorkspace();
|
|
76
|
+
using var fake = new FakeChatClient { MetadataProviderName = "morph-fake", MetadataDefaultModelId = "modelo-exemplar" };
|
|
77
|
+
WriteThreeScenarios(ws, fake);
|
|
78
|
+
|
|
79
|
+
await using var refresh = EvalFixture.Create(ws.Options(EvalMode.Refresh), fake);
|
|
80
|
+
await refresh.RunAllAsync(TestContext.Current.CancellationToken);
|
|
81
|
+
|
|
82
|
+
// Os resultados carregam latencia de cronometro, uso de tokens e o flag de cache hit:
|
|
83
|
+
// commita-los seria diff garantido em toda rodada, sem sinal nenhum. Por CONSTRUCAO
|
|
84
|
+
// (outra raiz), nunca por um .gitignore dentro de um diretorio commitado.
|
|
85
|
+
Assert.True(Directory.Exists(ws.ResultsRoot), "o store de resultados tem de existir depois de um run");
|
|
86
|
+
Assert.NotEmpty(Directory.EnumerateFiles(ws.ResultsRoot, "*", SearchOption.AllDirectories));
|
|
87
|
+
|
|
88
|
+
var full = Path.GetFullPath(ws.CacheRoot).Replace('\\', '/');
|
|
89
|
+
foreach (var file in Directory.EnumerateFiles(ws.ResultsRoot, "*", SearchOption.AllDirectories))
|
|
90
|
+
{
|
|
91
|
+
Assert.DoesNotContain(full, Path.GetFullPath(file).Replace('\\', '/'), StringComparison.OrdinalIgnoreCase);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
[Fact]
|
|
96
|
+
public async Task RefreshRewritesTheTreeAndCountsEveryScenarioAsAProviderCall()
|
|
97
|
+
{
|
|
98
|
+
using var ws = new EvalWorkspace();
|
|
99
|
+
using var fake = new FakeChatClient { MetadataProviderName = "morph-fake", MetadataDefaultModelId = "modelo-exemplar" };
|
|
100
|
+
WriteThreeScenarios(ws, fake);
|
|
101
|
+
|
|
102
|
+
await using var refresh = EvalFixture.Create(ws.Options(EvalMode.Refresh), fake);
|
|
103
|
+
var summary = await refresh.RunAllAsync(TestContext.Current.CancellationToken);
|
|
104
|
+
|
|
105
|
+
// O contador do cliente real e o que torna "a cache esta fria" uma MEDICAO: a arvore
|
|
106
|
+
// mudou e precisa ser commitada, e isso e afirmacao com consequencia.
|
|
107
|
+
Assert.Equal(3, summary.Misses);
|
|
108
|
+
Assert.Equal(EvalCacheState.Cold, summary.Cache);
|
|
109
|
+
Assert.Equal(3, fake.CallCount);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
[Fact]
|
|
113
|
+
public async Task ASecondRefreshZeroesTheTreeInsteadOfLayeringOverIt()
|
|
114
|
+
{
|
|
115
|
+
using var ws = new EvalWorkspace();
|
|
116
|
+
|
|
117
|
+
using (var fake = new FakeChatClient { MetadataProviderName = "morph-fake", MetadataDefaultModelId = "modelo-exemplar" })
|
|
118
|
+
{
|
|
119
|
+
WriteThreeScenarios(ws, fake);
|
|
120
|
+
await using var first = EvalFixture.Create(ws.Options(EvalMode.Refresh), fake);
|
|
121
|
+
await first.RunAllAsync(TestContext.Current.CancellationToken);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// O cenario do meio some do dataset. Uma regravacao PARCIAL deixaria a entrada antiga
|
|
125
|
+
// dele viva na arvore, e a proxima leitura offline nao teria como saber que ela nao
|
|
126
|
+
// corresponde mais a nenhum cenario declarado.
|
|
127
|
+
File.Delete(Path.Combine(ws.DatasetRoot, "triagem", "acme", "b-segundo.json"));
|
|
128
|
+
|
|
129
|
+
using (var fake = new FakeChatClient { MetadataProviderName = "morph-fake", MetadataDefaultModelId = "modelo-exemplar" })
|
|
130
|
+
{
|
|
131
|
+
fake.EnqueueText("resposta a-primeiro").EnqueueText("resposta c-terceiro");
|
|
132
|
+
await using var second = EvalFixture.Create(ws.Options(EvalMode.Refresh), fake);
|
|
133
|
+
await second.RunAllAsync(TestContext.Current.CancellationToken);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
var survivors = Fingerprint(ws.CacheRoot);
|
|
137
|
+
Assert.DoesNotContain(survivors, entry => entry.Contains("b-segundo", StringComparison.Ordinal));
|
|
138
|
+
Assert.Contains(survivors, entry => entry.Contains("a-primeiro", StringComparison.Ordinal));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
[Fact]
|
|
142
|
+
public async Task QualityEvaluatorsAreWiredAsREPORTEDMetrics_NeverAsTheGate()
|
|
143
|
+
{
|
|
144
|
+
using var ws = new EvalWorkspace();
|
|
145
|
+
using var fake = new FakeChatClient { MetadataProviderName = "morph-fake", MetadataDefaultModelId = "modelo-exemplar" };
|
|
146
|
+
|
|
147
|
+
// A lista vem do KIT, e nao do teste: e ela que da ao pacote .Quality cobertura de
|
|
148
|
+
// COMPILACAO em src/ (sem um using real la, o pin daquele pacote teria so contrato de
|
|
149
|
+
// versao, e um rename de API nao reprovaria PR nenhuma).
|
|
150
|
+
var quality = EvalQualityEvaluators.Stable();
|
|
151
|
+
|
|
152
|
+
await using var fixture = EvalFixture.Create(ws.Options(EvalMode.Refresh), fake, quality);
|
|
153
|
+
|
|
154
|
+
// A superficie existe e COMPILA contra as versoes do pin — que e o que um kit de
|
|
155
|
+
// template tem de garantir. O que ela NAO faz e decidir: o veredito de um cenario sai
|
|
156
|
+
// das assercoes deterministicas, e uma metrica de juizo entra no sumario como numero.
|
|
157
|
+
// Sem isso, a variancia de LLM reprovaria PRs e o no de eval nasceria destinado a ser
|
|
158
|
+
// desligado do CI.
|
|
159
|
+
Assert.Equal(2, quality.Count);
|
|
160
|
+
Assert.All(quality, evaluator => Assert.NotEmpty(evaluator.EvaluationMetricNames));
|
|
161
|
+
Assert.Empty(fixture.Summary.Quality);
|
|
162
|
+
|
|
163
|
+
// Uma lista NOVA a cada chamada: um array compartilhado seria estado global
|
|
164
|
+
// disfarcado de conveniencia, e a ReportingConfiguration guarda o que recebe.
|
|
165
|
+
Assert.NotSame(quality, EvalQualityEvaluators.Stable());
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
[Theory]
|
|
169
|
+
[InlineData("Microsoft.Extensions.AI.Evaluation.Quality.RelevanceEvaluator", false)]
|
|
170
|
+
[InlineData("Microsoft.Extensions.AI.Evaluation.Quality.CompletenessEvaluator", false)]
|
|
171
|
+
[InlineData("Microsoft.Extensions.AI.Evaluation.Quality.TaskAdherenceEvaluator", true)]
|
|
172
|
+
[InlineData("Microsoft.Extensions.AI.Evaluation.Quality.ToolCallAccuracyEvaluator", true)]
|
|
173
|
+
public void WhichQualityEvaluatorsAreExperimentalIsMEASURED_NotRemembered(string typeName, bool experimental)
|
|
174
|
+
{
|
|
175
|
+
// O plano descrevia os QUATRO como estaveis. Dois nao sao: eles carregam
|
|
176
|
+
// [Experimental("AIEVAL001")] na 10.9.0, e usa-los sob TreatWarningsAsErrors exigiria
|
|
177
|
+
// suprimir um diagnostico — uma decisao consciente, nunca um efeito colateral. Se um
|
|
178
|
+
// dia a Microsoft estabilizar (ou marcar) algum deles, este teste morre e a escolha e
|
|
179
|
+
// rediscutida com o fato na mao, em vez de envelhecer em silencio num comentario.
|
|
180
|
+
var type = typeof(RelevanceEvaluator).Assembly.GetType(typeName);
|
|
181
|
+
Assert.NotNull(type);
|
|
182
|
+
|
|
183
|
+
var attribute = type!
|
|
184
|
+
.GetCustomAttributes(typeof(System.Diagnostics.CodeAnalysis.ExperimentalAttribute), inherit: false)
|
|
185
|
+
.Cast<System.Diagnostics.CodeAnalysis.ExperimentalAttribute>()
|
|
186
|
+
.SingleOrDefault();
|
|
187
|
+
|
|
188
|
+
Assert.Equal(experimental, attribute is not null);
|
|
189
|
+
if (experimental)
|
|
190
|
+
{
|
|
191
|
+
Assert.Equal("AIEVAL001", attribute!.DiagnosticId);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
using Morph.AiKit.Evals;
|
|
2
|
+
|
|
3
|
+
namespace Morph.AiKit.Tests.Evals;
|
|
4
|
+
|
|
5
|
+
/// <summary>
|
|
6
|
+
/// Um diretorio temporario com dataset, cache e resultados — a montagem que todo teste de
|
|
7
|
+
/// eval precisa, escrita uma vez.
|
|
8
|
+
/// </summary>
|
|
9
|
+
/// <remarks>
|
|
10
|
+
/// Ele existe para que os testes falem de COMPORTAMENTO e nao de caminhos. E cada instancia
|
|
11
|
+
/// tem raiz propria (GUID v7): dois testes rodando lado a lado nao podem disputar a mesma
|
|
12
|
+
/// arvore de cache, senao a falha aparece como flake e nao como defeito.
|
|
13
|
+
/// </remarks>
|
|
14
|
+
internal sealed class EvalWorkspace : IDisposable
|
|
15
|
+
{
|
|
16
|
+
public EvalWorkspace()
|
|
17
|
+
{
|
|
18
|
+
Root = Path.Combine(Path.GetTempPath(), $"morph-eval-{Guid.CreateVersion7():N}");
|
|
19
|
+
Directory.CreateDirectory(CacheRoot);
|
|
20
|
+
Directory.CreateDirectory(DatasetRoot);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
public string Root { get; }
|
|
24
|
+
|
|
25
|
+
public string CacheRoot => Path.Combine(Root, "eval-cache");
|
|
26
|
+
|
|
27
|
+
public string DatasetRoot => Path.Combine(Root, "eval-datasets");
|
|
28
|
+
|
|
29
|
+
public string ResultsRoot => Path.Combine(Root, "eval-results");
|
|
30
|
+
|
|
31
|
+
public string SummaryPath => Path.Combine(Root, "summary.json");
|
|
32
|
+
|
|
33
|
+
/// <summary>O pin exemplar: a data e as versoes sob as quais a arvore diz ter sido gravada.</summary>
|
|
34
|
+
public static EvalPin Pin { get; } = new(
|
|
35
|
+
"2026-09-08",
|
|
36
|
+
new Dictionary<string, string>(StringComparer.Ordinal)
|
|
37
|
+
{
|
|
38
|
+
["Microsoft.Extensions.AI"] = "10.9.0",
|
|
39
|
+
["Microsoft.Extensions.AI.Evaluation"] = "10.9.0",
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/// <summary>Escreve um cenario no dataset e devolve o caminho do arquivo.</summary>
|
|
43
|
+
public string WriteScenario(string agent, string tenant, string name, string userText, string assertions)
|
|
44
|
+
{
|
|
45
|
+
var dir = Path.Combine(DatasetRoot, agent, tenant);
|
|
46
|
+
Directory.CreateDirectory(dir);
|
|
47
|
+
var file = Path.Combine(dir, $"{name}.json");
|
|
48
|
+
File.WriteAllText(file, $$"""
|
|
49
|
+
{
|
|
50
|
+
"messages": [ { "role": "user", "text": {{System.Text.Json.JsonSerializer.Serialize(userText)}} } ],
|
|
51
|
+
"assertions": {{assertions}}
|
|
52
|
+
}
|
|
53
|
+
""");
|
|
54
|
+
return file;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/// <summary>Opcoes prontas para um modo, com TTL longo e o pin exemplar.</summary>
|
|
58
|
+
public EvalCacheOptions Options(EvalMode mode, EvalPin? pin = null) => new()
|
|
59
|
+
{
|
|
60
|
+
CacheRoot = CacheRoot,
|
|
61
|
+
DatasetRoot = DatasetRoot,
|
|
62
|
+
ResultsRoot = ResultsRoot,
|
|
63
|
+
TimeToLive = TimeSpan.FromDays(3650),
|
|
64
|
+
ProviderName = "morph-fake",
|
|
65
|
+
DefaultModelId = "modelo-exemplar",
|
|
66
|
+
Mode = mode,
|
|
67
|
+
SummaryPath = SummaryPath,
|
|
68
|
+
Pin = pin ?? Pin,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
public void Dispose()
|
|
72
|
+
{
|
|
73
|
+
try
|
|
74
|
+
{
|
|
75
|
+
if (Directory.Exists(Root))
|
|
76
|
+
{
|
|
77
|
+
Directory.Delete(Root, recursive: true);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
catch (IOException)
|
|
81
|
+
{
|
|
82
|
+
// Um temporario que resiste a remocao nao pode derrubar um teste que ja provou o
|
|
83
|
+
// que veio provar.
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
using Morph.AiKit.Evals;
|
|
2
|
+
using Morph.AiKit.Tests.Fakes;
|
|
3
|
+
using Xunit;
|
|
4
|
+
|
|
5
|
+
namespace Morph.AiKit.Tests.Evals;
|
|
6
|
+
|
|
7
|
+
/// <summary>
|
|
8
|
+
/// Os evals DO PROPRIO KIT, contra a arvore de cache COMMITADA — sem credencial, sem rede.
|
|
9
|
+
/// </summary>
|
|
10
|
+
/// <remarks>
|
|
11
|
+
/// <para>
|
|
12
|
+
/// Esta classe e o exemplar que um projeto copia. Ela roda com a categoria <c>Eval</c>, le a
|
|
13
|
+
/// cache commitada em <c>eval-cache/</c> e o dataset versionado em <c>eval-datasets/</c>, e
|
|
14
|
+
/// termina verde com TODAS as variaveis de credencial ausentes. Se algum cenario nao estiver
|
|
15
|
+
/// na cache, ela fica VERMELHA nomeando o cenario — nunca <c>skip</c>.
|
|
16
|
+
/// </para>
|
|
17
|
+
/// <para>
|
|
18
|
+
/// <b>A cache foi semeada por FAKE, jamais por chamada paga</b> (ver <see cref="SeedTheCommittedCacheTree"/>).
|
|
19
|
+
/// O caminho de ESCRITA exercitado e o da biblioteca e a chave e computada pela mesma funcao
|
|
20
|
+
/// que a leitura usa — entao a leitura offline le exatamente o que aquela escrita produziu.
|
|
21
|
+
/// A lacuna residual, declarada em voz alta: isto NAO prova que uma cache gravada contra um
|
|
22
|
+
/// provider real e legivel offline. O que a mitiga e um fato medido — a chave depende de
|
|
23
|
+
/// <c>ProviderName</c> e <c>DefaultModelId</c>, e nao do endpoint, e os dois estao sob nosso
|
|
24
|
+
/// controle no cliente offline.
|
|
25
|
+
/// </para>
|
|
26
|
+
/// </remarks>
|
|
27
|
+
public sealed class KitEvals
|
|
28
|
+
{
|
|
29
|
+
/// <summary>Identidade sob a qual a arvore commitada foi gravada. Ela ENTRA NA CHAVE.</summary>
|
|
30
|
+
private const string Provider = "morph-fake";
|
|
31
|
+
|
|
32
|
+
/// <summary>Idem. Trocar qualquer um dos dois invalida a arvore inteira.</summary>
|
|
33
|
+
private const string Model = "morph-kit-exemplar";
|
|
34
|
+
|
|
35
|
+
/// <summary>
|
|
36
|
+
/// O diretorio do PROJETO de teste, achado subindo a partir do assembly.
|
|
37
|
+
/// </summary>
|
|
38
|
+
/// <remarks>
|
|
39
|
+
/// De proposito: a alternativa seria copiar a cache para <c>bin/</c> e ler a copia — e ai
|
|
40
|
+
/// o canario de "um run offline nao suja a arvore commitada" seria verdadeiro por
|
|
41
|
+
/// vacuidade, porque a arvore commitada nem teria sido tocada.
|
|
42
|
+
/// </remarks>
|
|
43
|
+
private static string ProjectDirectory
|
|
44
|
+
{
|
|
45
|
+
get
|
|
46
|
+
{
|
|
47
|
+
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
|
48
|
+
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "Morph.AiKit.Tests.csproj")))
|
|
49
|
+
{
|
|
50
|
+
dir = dir.Parent;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return dir?.FullName ?? throw new DirectoryNotFoundException(
|
|
54
|
+
"nao foi possivel localizar Morph.AiKit.Tests.csproj subindo a partir de " + AppContext.BaseDirectory);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private static string CacheRoot => Path.Combine(ProjectDirectory, "eval-cache");
|
|
59
|
+
|
|
60
|
+
private static string DatasetRoot => Path.Combine(ProjectDirectory, "eval-datasets");
|
|
61
|
+
|
|
62
|
+
/// <summary>O pin sob o qual a arvore commitada foi gravada — o mesmo que o carimbo carrega.</summary>
|
|
63
|
+
private static EvalPin Pin => new(
|
|
64
|
+
"2026-09-08",
|
|
65
|
+
new Dictionary<string, string>(StringComparer.Ordinal)
|
|
66
|
+
{
|
|
67
|
+
["Microsoft.Extensions.AI"] = "10.9.0",
|
|
68
|
+
["Microsoft.Extensions.AI.Evaluation"] = "10.9.0",
|
|
69
|
+
["Microsoft.Extensions.AI.Evaluation.Quality"] = "10.9.0",
|
|
70
|
+
["Microsoft.Extensions.AI.Evaluation.Reporting"] = "10.9.0",
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
private static EvalCacheOptions Options(EvalMode mode, string? summaryPath) => new()
|
|
74
|
+
{
|
|
75
|
+
CacheRoot = CacheRoot,
|
|
76
|
+
DatasetRoot = DatasetRoot,
|
|
77
|
+
// FORA da arvore commitada: os resultados carregam latencia, uso de tokens e o flag
|
|
78
|
+
// de cache hit, e commita-los seria diff garantido em toda rodada.
|
|
79
|
+
ResultsRoot = Path.Combine(Path.GetTempPath(), "morph-kit-eval-results"),
|
|
80
|
+
TimeToLive = TimeSpan.FromDays(3650),
|
|
81
|
+
ProviderName = Provider,
|
|
82
|
+
DefaultModelId = Model,
|
|
83
|
+
Mode = mode,
|
|
84
|
+
SummaryPath = summaryPath,
|
|
85
|
+
Pin = Pin,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
[Fact]
|
|
89
|
+
[Trait("Category", "Eval")]
|
|
90
|
+
public async Task TheKitEvalsRunGreenFromTheCommittedCacheWithNoCredentialAtAll()
|
|
91
|
+
{
|
|
92
|
+
// Nenhuma variavel de credencial e lida por nada nesta montagem — o cliente interno
|
|
93
|
+
// e o OfflineChatClient, que nao abre socket e nao le ambiente. Se a cache nao
|
|
94
|
+
// respondesse, este teste morreria com EvalCacheMissException nomeando o cenario.
|
|
95
|
+
var summaryPath = Path.Combine(Path.GetTempPath(), $"morph-kit-eval-{Guid.CreateVersion7():N}.json");
|
|
96
|
+
|
|
97
|
+
try
|
|
98
|
+
{
|
|
99
|
+
await using var fixture = EvalFixture.Create(Options(EvalMode.Offline, summaryPath));
|
|
100
|
+
var summary = await fixture.RunAllAsync(TestContext.Current.CancellationToken);
|
|
101
|
+
|
|
102
|
+
Assert.Equal(EvalCacheState.Warm, summary.Cache);
|
|
103
|
+
Assert.Equal("ok", summary.Stamp);
|
|
104
|
+
|
|
105
|
+
// NOMEANDO. `Assert.Equal(0, summary.Failed)` produzia "Expected 0, Actual 1" —
|
|
106
|
+
// verdadeiro e inutil: nao dizia qual cenario nem qual regua. A rubrica
|
|
107
|
+
// eval-de-modelo.md exige, na banda 9-10, derrubar o cenario NOMEANDO-O.
|
|
108
|
+
Assert.True(summary.Failed == 0, summary.DescribeFailures());
|
|
109
|
+
Assert.Empty(summary.Failures);
|
|
110
|
+
Assert.Equal(summary.Scenarios, summary.Passed);
|
|
111
|
+
|
|
112
|
+
// A prova central, e ela e um CONTADOR: o cliente interno e o unico caminho para
|
|
113
|
+
// a rede, e ele nao foi invocado nenhuma vez.
|
|
114
|
+
Assert.Equal(0, summary.Misses);
|
|
115
|
+
Assert.Equal(0, fixture.Offline!.Invocations);
|
|
116
|
+
|
|
117
|
+
// O dataset nao pode encolher em silencio: uma suite de eval que passa a avaliar
|
|
118
|
+
// menos cenarios e a mesma classe de defeito que uma baseline de teste caindo.
|
|
119
|
+
Assert.Equal(4, summary.Scenarios);
|
|
120
|
+
}
|
|
121
|
+
finally
|
|
122
|
+
{
|
|
123
|
+
if (File.Exists(summaryPath))
|
|
124
|
+
{
|
|
125
|
+
File.Delete(summaryPath);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
[Fact]
|
|
131
|
+
[Trait("Category", "Eval")]
|
|
132
|
+
public void TheCommittedCacheCarriesTheStampOfThePinItWasWrittenUnder()
|
|
133
|
+
{
|
|
134
|
+
var stamp = EvalCacheStamp.Read(CacheRoot);
|
|
135
|
+
|
|
136
|
+
Assert.NotNull(stamp);
|
|
137
|
+
Assert.Equal("2026-09-08", stamp!.PinnedAt);
|
|
138
|
+
Assert.Equal(Provider, stamp.Provider);
|
|
139
|
+
Assert.Equal(Model, stamp.Model);
|
|
140
|
+
Assert.Equal("10.9.0", stamp.Packages["Microsoft.Extensions.AI.Evaluation"]);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
[Fact]
|
|
144
|
+
[Trait("Category", "Eval")]
|
|
145
|
+
public void TheCommittedCacheCarriesNoABSOLUTEPathOfTheMachineThatWroteIt()
|
|
146
|
+
{
|
|
147
|
+
// A premissa que torna a arvore commitavel: nada dentro dela aponta para o disco de
|
|
148
|
+
// quem gravou. Medido, e nao suposto — se um dia a biblioteca passar a gravar o
|
|
149
|
+
// storageRootPath dentro das entradas, este canario morde.
|
|
150
|
+
var root = ProjectDirectory.Replace('\\', '/');
|
|
151
|
+
var drive = Path.GetPathRoot(ProjectDirectory)?.Replace('\\', '/');
|
|
152
|
+
|
|
153
|
+
// A raiz so' identifica uma MAQUINA quando e' uma unidade nomeada ("R:/"). Em POSIX
|
|
154
|
+
// `GetPathRoot` devolve "/", que nao identifica nada — e exigir que a cache nao
|
|
155
|
+
// contenha "/" reprova qualquer arquivo com um path ou uma URL dentro. O canario
|
|
156
|
+
// passava no Windows e era ABSURDO no Linux: media "nenhuma barra em lugar nenhum".
|
|
157
|
+
// Medido no CI (job ai-kit, ubuntu): 1 falha, `Assert.DoesNotContain() Sub-string found`.
|
|
158
|
+
var namedDrive = drive is { Length: > 1 } && drive.Contains(':');
|
|
159
|
+
|
|
160
|
+
foreach (var file in Directory.EnumerateFiles(CacheRoot, "*", SearchOption.AllDirectories))
|
|
161
|
+
{
|
|
162
|
+
var text = File.ReadAllText(file).Replace("\\\\", "/").Replace('\\', '/');
|
|
163
|
+
Assert.DoesNotContain(root, text, StringComparison.OrdinalIgnoreCase);
|
|
164
|
+
if (namedDrive)
|
|
165
|
+
{
|
|
166
|
+
Assert.DoesNotContain(drive!, text, StringComparison.OrdinalIgnoreCase);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/// <summary>
|
|
172
|
+
/// Regrava a arvore commitada a partir do fake. NAO roda em CI.
|
|
173
|
+
/// </summary>
|
|
174
|
+
/// <remarks>
|
|
175
|
+
/// <para>
|
|
176
|
+
/// Ele existe committed, e nao como um script que alguem rodou uma vez e jogou fora,
|
|
177
|
+
/// porque a proxima pessoa que subir o pin precisa saber COMO regravar — e uma instrucao
|
|
178
|
+
/// em prosa envelhece; um teste executavel nao.
|
|
179
|
+
/// </para>
|
|
180
|
+
/// <para>
|
|
181
|
+
/// A porta e uma variavel de ambiente, e nao um <c>[Fact(Skip=...)]</c> fixo, porque um
|
|
182
|
+
/// skip fixo nunca poderia ser exercitado: seria um seeder que ninguem consegue rodar.
|
|
183
|
+
/// Rode com <c>MORPH_EVAL_SEED=1 dotnet test</c> e commite a arvore que sair.
|
|
184
|
+
/// </para>
|
|
185
|
+
/// </remarks>
|
|
186
|
+
[Fact]
|
|
187
|
+
public async Task SeedTheCommittedCacheTree()
|
|
188
|
+
{
|
|
189
|
+
Assert.SkipUnless(
|
|
190
|
+
Environment.GetEnvironmentVariable("MORPH_EVAL_SEED") == "1",
|
|
191
|
+
"semeadura da cache exemplar: rode com MORPH_EVAL_SEED=1 e commite a arvore resultante");
|
|
192
|
+
|
|
193
|
+
using var fake = new FakeChatClient
|
|
194
|
+
{
|
|
195
|
+
MetadataProviderName = Provider,
|
|
196
|
+
MetadataDefaultModelId = Model,
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
// Uma resposta por cenario, na ORDEM do dataset (ordinal por caminho relativo):
|
|
200
|
+
// orcamento/acme/a-resposta-estruturada, triagem/acme/a-saudacao,
|
|
201
|
+
// triagem/acme/b-escalonamento, triagem/globex/a-saudacao.
|
|
202
|
+
fake.EnqueueText("""{"valor":"R$ 1.200,00","prazo":"5 dias uteis"}""")
|
|
203
|
+
.EnqueueText("Bom dia! Como posso ajudar voce hoje?")
|
|
204
|
+
.EnqueueText("Encaminhando voce para um atendente humano agora.")
|
|
205
|
+
.EnqueueText("Bom dia! Aqui e o atendimento da Globex.");
|
|
206
|
+
|
|
207
|
+
await using var fixture = EvalFixture.Create(Options(EvalMode.Refresh, summaryPath: null), fake);
|
|
208
|
+
var summary = await fixture.RunAllAsync(TestContext.Current.CancellationToken);
|
|
209
|
+
|
|
210
|
+
Assert.Equal(summary.Scenarios, summary.Passed);
|
|
211
|
+
Assert.Equal(EvalCacheState.Cold, summary.Cache);
|
|
212
|
+
}
|
|
213
|
+
}
|
package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/OfflineChatClientTests.cs
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
using Microsoft.Extensions.AI;
|
|
2
|
+
using Morph.AiKit.Evals;
|
|
3
|
+
using Xunit;
|
|
4
|
+
|
|
5
|
+
namespace Morph.AiKit.Tests.Evals;
|
|
6
|
+
|
|
7
|
+
/// <summary>
|
|
8
|
+
/// O cliente que RECUSA: a peca de que depende "o eval roda no CI sem credencial".
|
|
9
|
+
/// </summary>
|
|
10
|
+
public sealed class OfflineChatClientTests
|
|
11
|
+
{
|
|
12
|
+
private static OfflineChatClient Build(Func<string>? scenario = null)
|
|
13
|
+
=> new("morph-fake", "modelo-exemplar", "/tmp/eval-cache", ".env.evals", scenario ?? (() => "triagem.acme.saudacao"));
|
|
14
|
+
|
|
15
|
+
[Fact]
|
|
16
|
+
public void ItReplicatesTheProviderAndModelOfTheRunThatWroteTheCache()
|
|
17
|
+
{
|
|
18
|
+
using var client = Build();
|
|
19
|
+
|
|
20
|
+
var metadata = Assert.IsType<ChatClientMetadata>(client.GetService(typeof(ChatClientMetadata)));
|
|
21
|
+
|
|
22
|
+
// Estes DOIS campos entram na chave de cache. Se divergissem do run que gravou, todo
|
|
23
|
+
// cenario daria miss e a cache quente seria indistinguivel de uma cache vazia.
|
|
24
|
+
Assert.Equal("morph-fake", metadata.ProviderName);
|
|
25
|
+
Assert.Equal("modelo-exemplar", metadata.DefaultModelId);
|
|
26
|
+
|
|
27
|
+
// E o endpoint e NULO de proposito: ele nao entra na chave (medido na v10.9.0), e um
|
|
28
|
+
// cliente que nunca liga para lugar nenhum nao deve anunciar um destino.
|
|
29
|
+
Assert.Null(metadata.ProviderUri);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
[Fact]
|
|
33
|
+
public async Task ARequestRefusesInsteadOfReachingTheNetwork()
|
|
34
|
+
{
|
|
35
|
+
using var client = Build();
|
|
36
|
+
|
|
37
|
+
var error = await Assert.ThrowsAsync<EvalCacheMissException>(
|
|
38
|
+
() => client.GetResponseAsync([new ChatMessage(ChatRole.User, "oi")], cancellationToken: TestContext.Current.CancellationToken));
|
|
39
|
+
|
|
40
|
+
Assert.Equal("triagem.acme.saudacao", error.ScenarioName);
|
|
41
|
+
Assert.Equal("/tmp/eval-cache", error.CachePath);
|
|
42
|
+
Assert.Equal(".env.evals", error.EnvFile);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
[Fact]
|
|
46
|
+
public void TheCounterRisesOnEveryAttempt_ItIsTheProofOfZeroPaidCalls()
|
|
47
|
+
{
|
|
48
|
+
using var client = Build();
|
|
49
|
+
Assert.Equal(0, client.Invocations);
|
|
50
|
+
|
|
51
|
+
for (var i = 1; i <= 3; i++)
|
|
52
|
+
{
|
|
53
|
+
Assert.Throws<EvalCacheMissException>(
|
|
54
|
+
() => client.GetStreamingResponseAsync([new ChatMessage(ChatRole.User, "oi")], cancellationToken: TestContext.Current.CancellationToken));
|
|
55
|
+
Assert.Equal(i, client.Invocations);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
[Fact]
|
|
60
|
+
public void TheScenarioNameIsReadAtCALLTIME_NotCaptured()
|
|
61
|
+
{
|
|
62
|
+
// Um fixture roda N cenarios com o MESMO cliente. Se o nome fosse capturado na
|
|
63
|
+
// construcao, toda mensagem de miss nomearia o primeiro cenario e mandaria o leitor
|
|
64
|
+
// investigar o arquivo errado.
|
|
65
|
+
var current = "primeiro";
|
|
66
|
+
using var client = Build(() => current);
|
|
67
|
+
|
|
68
|
+
var first = Assert.Throws<EvalCacheMissException>(
|
|
69
|
+
() => client.GetStreamingResponseAsync([new ChatMessage(ChatRole.User, "oi")], cancellationToken: TestContext.Current.CancellationToken));
|
|
70
|
+
Assert.Equal("primeiro", first.ScenarioName);
|
|
71
|
+
|
|
72
|
+
current = "segundo";
|
|
73
|
+
var second = Assert.Throws<EvalCacheMissException>(
|
|
74
|
+
() => client.GetStreamingResponseAsync([new ChatMessage(ChatRole.User, "oi")], cancellationToken: TestContext.Current.CancellationToken));
|
|
75
|
+
Assert.Equal("segundo", second.ScenarioName);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
[Fact]
|
|
79
|
+
public void ItRefusesToBeBuiltWithoutTheIdentityThatEntersTheCacheKey()
|
|
80
|
+
{
|
|
81
|
+
Assert.Throws<ArgumentException>(() => new OfflineChatClient(" ", "m", "/tmp", ".env", () => "s"));
|
|
82
|
+
Assert.Throws<ArgumentException>(() => new OfflineChatClient("p", "m", " ", ".env", () => "s"));
|
|
83
|
+
Assert.Throws<ArgumentNullException>(() => new OfflineChatClient("p", "m", "/tmp", ".env", null!));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
[Fact]
|
|
87
|
+
public void GetServiceAnswersLikeARealClientAndNeverInventsAKeyedService()
|
|
88
|
+
{
|
|
89
|
+
using var client = Build();
|
|
90
|
+
|
|
91
|
+
Assert.Same(client, client.GetService(typeof(IChatClient)));
|
|
92
|
+
Assert.Null(client.GetService(typeof(ChatClientMetadata), serviceKey: "qualquer"));
|
|
93
|
+
Assert.Null(client.GetService(typeof(string)));
|
|
94
|
+
}
|
|
95
|
+
}
|