Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -376,8 +376,15 @@ signsofai check article.docx --lang en # Word documents too
signsofai check post.md --json # machine-readable
signsofai check post.md --max-score 40 # exit 1 if it reads too much like AI → fails CI
signsofai check post.md --rules my-style.json # your custom catalog
signsofai check ensayo.txt --lang es --reader-lang en --report out.md
```

`--lang` is the language of the **text**; `--reader-lang` is the language of whoever reads the
output — the evidence report, the character scan and the citation cross-check, all of which address
that person rather than describe the prose. It defaults to the text's language, so you only pass it
when the two differ. Findings stay in the text's language on purpose: a Spanish tell is explained in
Spanish.

The analysis engine is also a library — `dotnet add package SignsOfAI.Core`:

```csharp
Expand Down
4 changes: 4 additions & 0 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,9 +196,13 @@ dotnet tool install --global SignsOfAI.Cli
signsofai check draft.md --json # the analysis, structured
signsofai check essay.docx --report out.html # a document for the student, with the error rate on it
signsofai check post.md --max-score 40 # gate prose in CI
signsofai check ensayo.docx --reader-lang es --report out.html # the report in the reader's language
signsofai baseline essay4.docx --against essay1.docx --against essay2.docx --against essay3.docx
```

`--reader-lang` is who reads the output, not what language the text is in — a teacher reading Spanish
essays in English wants `--reader-lang en`. It defaults to the text's language.

Or the web app, which runs in the browser with nothing installed and uploads nothing:
https://peopleworks.github.io/SignsofAI/

Expand Down
19 changes: 16 additions & 3 deletions src/SignsOfAI.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@
string language = "auto";
bool json = false, noColor = false, failOnArtifacts = false;
string? reportPath = null;
string? readerLang = null;
double? maxScore = null;
int top = 10;

Expand All @@ -140,6 +141,10 @@
case "--lang": language = Next(); break;
case "--json": json = true; break;
case "--report": reportPath = Next(); break;
// The language of whoever reads the output, which is not always the document's: the
// report, the character scan and the citation cross-check all address that person.
// Defaults to the text's language, so nothing changes unless it is asked for.
case "--reader-lang": readerLang = Next(); break;
case "--no-color": noColor = true; break;
case "--rules": ruleFiles.Add(Next()); break;
case "--max-score": maxScore = double.Parse(Next(), System.Globalization.CultureInfo.InvariantCulture); break;
Expand All @@ -154,7 +159,7 @@

if (positionals.Count == 0)
{
Console.Error.WriteLine("Usage: signsofai check <path> [--lang auto|en|es] [--json] [--report FILE] [--max-score N] [--fail-on-artifacts] [--top N]");
Console.Error.WriteLine("Usage: signsofai check <path> [--lang auto|en|es] [--reader-lang en|es] [--json] [--report FILE] [--max-score N] [--fail-on-artifacts] [--top N]");
return 2;
}

Expand All @@ -173,7 +178,7 @@
catch (Exception ex) { Console.Error.WriteLine($"Invalid rule-pack '{rf}': {ex.Message}"); return 2; }
}

var result = new AiWritingAnalyzer().Analyze(text, language, extraPacks);
var result = new AiWritingAnalyzer().Analyze(text, language, extraPacks, readerLang);

if (json)
{
Expand Down Expand Up @@ -244,7 +249,15 @@
// --max-score gate still leaves the document behind for whoever has to look at it.
if (reportPath is not null)
{
var options = new ReportOptions { DocumentName = Path.GetFileName(path) };
// Without this the report was English whatever was asked for, because the CLI never set it
// (#77) — report.es.json was unreachable from a command line. An unsupported language falls
// back to English inside ReportMessages and the report says so on its face, so passing the
// detected language through is safe.
var options = new ReportOptions
{
DocumentName = Path.GetFileName(path),
InterfaceLanguage = readerLang ?? result.Language,
};
var markdown = Path.GetExtension(reportPath).Equals(".md", StringComparison.OrdinalIgnoreCase);
var document = markdown
? EvidenceReport.ToMarkdown(result, options)
Expand Down
41 changes: 38 additions & 3 deletions src/SignsOfAI.Core/AiWritingAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,17 @@ public static IReadOnlyList<IAnalyzer> DefaultAnalyzers() =>
/// A pack applies when its <c>Language</c> matches (or is "*"/"all"/empty); rules override
/// built-ins by id.
/// </param>
public AnalysisResult Analyze(string text, string? language = null, IReadOnlyList<RulePack>? extraPacks = null)
/// <param name="readerLanguage">
/// The language of whoever is reading the result, when it differs from the text's. It governs
/// only what is addressed to that reader rather than said about the prose — see the character
/// scan and the citation cross-check below. Null follows the text, which is what a caller with
/// no interface of its own wants.
/// </param>
public AnalysisResult Analyze(
string text,
string? language = null,
IReadOnlyList<RulePack>? extraPacks = null,
string? readerLanguage = null)
{
text ??= string.Empty;

Expand All @@ -56,13 +66,23 @@ public AnalysisResult Analyze(string text, string? language = null, IReadOnlyLis

var rulePack = ResolvePack(lang, extraPacks);

// Both of the checks below state facts about the file rather than judgements of its prose,
// and everything they say is addressed to whoever is reading: U+00A0 is U+00A0 in every
// language, and "ask the writer how this document was produced" is an instruction, not
// commentary. So they take the reader's pack, which #36 settled for the evidence report and
// #88 found these two had never been given.
//
// Findings are different and deliberately untouched: a finding quotes the text and argues
// about it, and a Spanish tell explained in Spanish is the useful form.
var readerPack = ResolveReaderPack(readerLanguage, lang, rulePack, extraPacks);

// Re-scanned only when there is something to report, this time with the pack that supplies
// the wording — the first pass runs before the language is known.
var artifacts = probe.Any ? ArtifactScanner.Scan(text, rulePack) : ArtifactReport.Empty;
var artifacts = probe.Any ? ArtifactScanner.Scan(text, readerPack) : ArtifactReport.Empty;

// Sources are read from the cleaned copy too, so a substituted letter cannot hide a citation
// from its own bibliography any more than it can hide a word from the catalog.
var citations = ToSource(CitationChecker.Check(normalized.Text, rulePack), normalized);
var citations = ToSource(CitationChecker.Check(normalized.Text, readerPack), normalized);

var context = new AnalysisContext
{
Expand Down Expand Up @@ -141,6 +161,21 @@ private static Finding ToSource(Finding finding, NormalizedText normalized, stri
/// replacements — has to consult the very same merged pack. Re-deriving it at the call site is how
/// the two drift apart.
/// </summary>
/// <summary>
/// The pack that supplies wording addressed to the reader. Falls back to the analysed text's
/// pack whenever no reader language is given or it is the same one — so a caller that never
/// heard of this keeps exactly the behaviour it had.
/// </summary>
private static RulePack ResolveReaderPack(
string? readerLanguage, string textLanguage, RulePack textPack, IReadOnlyList<RulePack>? extraPacks)
{
if (readerLanguage is null or "" or "auto")
return textPack;

var reader = readerLanguage.ToLowerInvariant();
return reader == textLanguage ? textPack : ResolvePack(reader, extraPacks);
}

public static RulePack ResolvePack(string language, IReadOnlyList<RulePack>? extraPacks = null)
{
var builtIn = RulePackLoader.Load(language);
Expand Down
20 changes: 18 additions & 2 deletions src/SignsOfAI.Core/Artifacts/Characters.cs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,23 @@ cp is (>= 0x1D400 and <= 0x1D7FF) // Mathematical Alphanumeric Symbols
/// What is deliberately absent: every accented Latin letter. "á", "ñ" and "ü" are ordinary
/// Spanish, and a table that treated them as impostors would turn this check into exactly the
/// kind of instrument that punishes people for the language they write in.
///
/// That principle was written for the two languages this project ships and applied only to them,
/// which is how U+0131 DOTLESS I — ordinary Turkish, Azerbaijani, Crimean Tatar and Kazakh — sat
/// here until #62. It flagged the proper names Fazıl, Kıbrıs, Rıza and Komandoları in a Spanish
/// article about a Turkish organisation: the tool penalising a document for being multilingual,
/// which is the harm the calibration page exists to measure.
///
/// So the line is not "Latin or not" but **is this a letter of some living alphabet**. The three
/// entries below survive it because they are phonetic symbols from the IPA extensions, which no
/// national orthography writes prose in — a "ɡ" in running text is anomalous in a way a "ı" is
/// not. Nothing outside that reasoning may be added: a table that treats a nation's alphabet as
/// a disguise is the instrument this project argues against.
///
/// The cost is real and is the right way to be wrong: a substitution that swaps ı for i is no
/// longer caught. It has never been observed — U+0131 occurs in one of the 371 texts we hold, and
/// that one is Turkish names — and this check errs toward a missed artifact over a false
/// accusation, the direction <see cref="IsEmojiLike"/> already states.
/// </summary>
private static readonly Dictionary<int, char> Table = new()
{
Expand All @@ -129,8 +146,7 @@ cp is (>= 0x1D400 and <= 0x1D7FF) // Mathematical Alphanumeric Symbols
[0x03A1] = 'P', [0x03A4] = 'T', [0x03A5] = 'Y', [0x03A7] = 'X',
// Armenian
[0x0570] = 'h', [0x0578] = 'n', [0x057D] = 'u', [0x0585] = 'o',
// Latin letters that are not the ASCII one they look like
[0x0131] = 'i', // DOTLESS I
// IPA extensions: phonetic symbols, not letters of anyone's alphabet
[0x0251] = 'a', // LATIN SMALL LETTER ALPHA
[0x0261] = 'g', // LATIN SMALL LETTER SCRIPT G
[0x0269] = 'i', // LATIN SMALL LETTER IOTA
Expand Down
Loading
Loading